summaryrefslogtreecommitdiff
path: root/core/src/commonMain/kotlin/coffee/liz
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 09:36:32 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 09:36:32 -0800
commit60426ce45763d0a4a5572db6926e8e677cbc0dc0 (patch)
tree0b8726b1d3f493cbf5adcecf10997e3c72e27762 /core/src/commonMain/kotlin/coffee/liz
parent6004b4cda755202491648e0d724b66d50eb0b96a (diff)
downloadrookery-60426ce45763d0a4a5572db6926e8e677cbc0dc0.tar.gz
rookery-60426ce45763d0a4a5572db6926e8e677cbc0dc0.zip
Plain UI copy; smart cube pairing; stackmat input
- Remove marquee, snow, pointer-following, hostname line and penguin speech; the penguin keeps to its corner. UI text is plain labels. - Smart cube: scan / pair / connect / forget in settings, with live battery, move count and an unfolded view of the cube's state, and "mark solved" to resync. Paired cubes go in the synced device list. CubeBackend abstracts the platform; Linux (BlueZ) only for now. - Stackmat: audio decoder for the timer's 1200-baud data port (edge based, so AC coupling and inverted inputs work; 5- and 6-digit packets, checksum verified) and a session that records each solve once. Settings pick the input and show signal state; when enabled the mat drives the timer display and lamps instead of the keyboard. Tested against synthetic audio only. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Diffstat (limited to 'core/src/commonMain/kotlin/coffee/liz')
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt9
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/stackmat/Stackmat.kt120
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt6
3 files changed, 135 insertions, 0 deletions
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt
index f0e9cee..96e45e7 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt
@@ -49,6 +49,15 @@ sealed interface SmartCubeEvent {
}
/** A connected smart cube. [events] is hot: collect it before issuing requests to see their replies. */
+/** What a platform offers for finding and connecting smart cubes. */
+interface CubeBackend {
+ /** Nearby smart cubes, as they're heard. Collect to scan; cancel to stop. */
+ fun scan(): Flow<BleAdvertisement>
+
+ /** [mac] is the cube's real MAC (needed for decryption) where the platform's [address] is something else. */
+ suspend fun connect(scope: kotlinx.coroutines.CoroutineScope, address: String, mac: String): SmartCube
+}
+
interface SmartCube {
val name: String?
val address: String
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/stackmat/Stackmat.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/stackmat/Stackmat.kt
new file mode 100644
index 0000000..0507fa8
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/stackmat/Stackmat.kt
@@ -0,0 +1,120 @@
+package coffee.liz.rookery.stackmat
+
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.roundToInt
+
+/**
+ * One status packet from a speed-stacks timer.
+ * [status]: ' ' running, 'S' stopped, 'I' reset, 'A' both hands down and ready, 'L'/'R' one hand, 'C' both hands.
+ */
+data class StackmatPacket(val status: Char, val ms: Long, val digits: Int) {
+ val running: Boolean get() = status == ' '
+ val handsDown: Boolean get() = status == 'L' || status == 'R' || status == 'C' || status == 'A'
+ val ready: Boolean get() = status == 'A'
+}
+
+/**
+ * Decodes the timer's data port from audio samples: 1200 baud serial (start bit, 8 data bits LSB
+ * first, stop bit), packets of status + 5 or 6 digits + checksum (64 + digit sum) + line ending.
+ *
+ * Sound cards are AC-coupled and may invert the signal, so levels are tracked by edges rather than
+ * by sign, and whichever level the line rests at between packets is taken as logical 1.
+ */
+class StackmatDecoder(sampleRate: Int, private val onPacket: (StackmatPacket) -> Unit) {
+ private val spb = sampleRate / 1200.0
+ private val w = max(1, (spb / 3).toInt())
+ private val ring = FloatArray(w)
+ private var ringAt = 0
+ private var peak = 0f
+ private var level = false
+ private var run = 0L
+
+ private var idle: Boolean? = null
+ private val bits = ArrayList<Boolean>(32)
+ private val tail = ArrayList<Int>(12)
+
+ /** Size of recent signal edges, 0..~2: for a level meter. */
+ val signal: Float get() = peak
+
+ fun feed(samples: FloatArray, count: Int = samples.size) {
+ for (i in 0 until count) {
+ val x = samples[i]
+ val d = x - ring[ringAt]
+ ring[ringAt] = x; ringAt = (ringAt + 1) % w
+ peak = max(peak * 0.99995f, abs(d))
+ run++
+ if (peak > 0.02f) {
+ val next = if (d > 0.4f * peak) true else if (d < -0.4f * peak) false else level
+ if (next != level) { runEnded(level, run); level = next; run = 0 }
+ }
+ // the last byte of a packet has no following edge to end it: time it out
+ if (run == (12 * spb).toLong()) runEnded(level, run, stillGoing = true)
+ }
+ }
+
+ private fun runEnded(lvl: Boolean, samples: Long, stillGoing: Boolean = false) {
+ val n = (samples / spb).roundToInt()
+ if (n >= 12) {
+ // a long rest: this is the idle level; finish any byte in flight with stop bits
+ if (idle == lvl && bits.isNotEmpty()) { repeat(10) { bits += lvl }; drain() }
+ idle = lvl; bits.clear()
+ return
+ }
+ if (stillGoing) return
+ val idleLevel = idle ?: return
+ if (n == 0 || (bits.isEmpty() && lvl == idleLevel)) return
+ repeat(n) { bits += lvl }
+ drain()
+ }
+
+ private fun drain() {
+ val idleLevel = idle ?: return
+ while (bits.size >= 10) {
+ if (bits[0] == idleLevel || bits[9] != idleLevel) { bits.clear(); idle = null; return } // framing lost: wait for the next rest
+ var byte = 0
+ for (k in 0 until 8) if (bits[1 + k] == idleLevel) byte = byte or (1 shl k)
+ repeat(10) { bits.removeAt(0) }
+ while (bits.isNotEmpty() && bits[0] == idleLevel) bits.removeAt(0)
+ byte(byte)
+ }
+ }
+
+ private fun byte(b: Int) {
+ tail += b
+ if (tail.size > 8) tail.removeAt(0)
+ // does the tail end in status, digits, checksum?
+ for (digits in intArrayOf(6, 5)) {
+ if (tail.size < digits + 2) continue
+ val at = tail.size - digits - 2
+ val status = tail[at].toChar()
+ if (status !in " SILRCA") continue
+ val ds = tail.subList(at + 1, at + 1 + digits)
+ if (ds.any { it !in '0'.code..'9'.code }) continue
+ if (64 + ds.sumOf { it - '0'.code } != tail.last()) continue
+ val v = ds.map { it - '0'.code }
+ val ms = if (digits == 6) v[0] * 60_000L + (v[1] * 10 + v[2]) * 1000L + v[3] * 100 + v[4] * 10 + v[5]
+ else v[0] * 60_000L + (v[1] * 10 + v[2]) * 1000L + (v[3] * 10 + v[4]) * 10L
+ tail.clear()
+ onPacket(StackmatPacket(status, ms, digits))
+ return
+ }
+ }
+}
+
+/**
+ * Turns the packet stream into solves: a result is taken when the timer shows a stopped, non-zero
+ * time after having been reset, so a packet lost at the moment of stopping doesn't lose the solve
+ * and a time left on the display isn't recorded twice.
+ */
+class StackmatSession(private val onSolve: (ms: Long) -> Unit) {
+ var last: StackmatPacket? = null
+ private set
+ private var armed = false
+
+ fun packet(p: StackmatPacket) {
+ last = p
+ if (p.ms == 0L || p.running) armed = true
+ else if (p.status == 'S' && armed) { armed = false; onSolve(p.ms) }
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt
index 3e04f11..a7a0994 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt
@@ -194,6 +194,12 @@ class Repo(val store: Store) {
r.entities(Coll.DEVICE).map { (mac, f) -> CubeDevice(mac, f.str("name") ?: mac, f.str("proto") ?: "unknown") }
}
+ fun saveDevice(d: CubeDevice) = store.transact {
+ set(Coll.DEVICE, d.mac, "name", d.name); set(Coll.DEVICE, d.mac, "proto", d.protocol); set(Coll.DEVICE, d.mac, "_del", false)
+ }
+
+ fun forgetDevice(mac: String) = store.transact { delete(Coll.DEVICE, mac) }
+
fun pref(key: String): String? = store.read { r -> r.entity(Coll.PREFS, "global")?.str(key) }
fun boolPref(key: String, default: Boolean): Boolean =
store.read { r -> r.entity(Coll.PREFS, "global")?.bool(key) } ?: default