From 60426ce45763d0a4a5572db6926e8e677cbc0dc0 Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Fri, 13 Nov 2026 09:36:32 -0800 Subject: 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 --- .../kotlin/coffee/liz/rookery/bt/SmartCube.kt | 9 ++ .../kotlin/coffee/liz/rookery/stackmat/Stackmat.kt | 120 +++++++++++++++++++++ .../kotlin/coffee/liz/rookery/store/Repo.kt | 6 ++ 3 files changed, 135 insertions(+) create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/stackmat/Stackmat.kt (limited to 'core/src/commonMain/kotlin/coffee') 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 + + /** [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(32) + private val tail = ArrayList(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 -- cgit v1.3