diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 09:36:32 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 09:36:32 -0800 |
| commit | 60426ce45763d0a4a5572db6926e8e677cbc0dc0 (patch) | |
| tree | 0b8726b1d3f493cbf5adcecf10997e3c72e27762 /core/src | |
| parent | 6004b4cda755202491648e0d724b66d50eb0b96a (diff) | |
| download | rookery-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')
4 files changed, 218 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 diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt new file mode 100644 index 0000000..f6071ff --- /dev/null +++ b/core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt @@ -0,0 +1,83 @@ +package coffee.liz.rookery + +import coffee.liz.rookery.stackmat.StackmatDecoder +import coffee.liz.rookery.stackmat.StackmatPacket +import coffee.liz.rookery.stackmat.StackmatSession +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class StackmatTest { + private fun packetBytes(status: Char, digits: String): List<Int> = + listOf(status.code) + digits.map { it.code } + (64 + digits.sumOf { it - '0' }) + listOf('\n'.code, '\r'.code) + + /** Audio for a list of packets, the way a sound card hears it: optional inversion, AC coupling, noise. */ + private fun audio(packets: List<List<Int>>, rate: Int, invert: Boolean, noise: Float, rng: Random = Random(1)): FloatArray { + val bits = ArrayList<Boolean>() + repeat(40) { bits += true } + for (p in packets) { + for (b in p) { bits += false; for (k in 0 until 8) bits += (b shr k) and 1 == 1; bits += true } + repeat(30) { bits += true } // rest between packets + } + val spb = rate / 1200.0 + val n = (bits.size * spb).toInt() + val out = FloatArray(n) + var hp = 0f; var prev = 0f + val alpha = 1f - 1f / (0.004f * rate) // ~4 ms high-pass + for (i in 0 until n) { + val raw = (if (bits[(i / spb).toInt()] != invert) 0.5f else -0.5f) + hp = alpha * (hp + raw - prev); prev = raw + out[i] = hp + (rng.nextFloat() - 0.5f) * noise + } + return out + } + + private fun decode(samples: FloatArray, rate: Int, chunk: Int = 512): List<StackmatPacket> { + val got = mutableListOf<StackmatPacket>() + val d = StackmatDecoder(rate) { got += it } + var at = 0 + while (at < samples.size) { val n = minOf(chunk, samples.size - at); d.feed(samples.copyOfRange(at, at + n)); at += n } + return got + } + + private val sequence = listOf(packetBytes('I', "000000"), packetBytes('A', "000000"), packetBytes(' ', "001234"), packetBytes(' ', "012345"), packetBytes('S', "112345"), packetBytes('S', "112345")) + private val expected = listOf( + StackmatPacket('I', 0, 6), StackmatPacket('A', 0, 6), StackmatPacket(' ', 1_234, 6), StackmatPacket(' ', 12_345, 6), + StackmatPacket('S', 72_345, 6), StackmatPacket('S', 72_345, 6), + ) + + @Test fun decodesCleanSignalAtCommonRates() { + for (rate in listOf(44_100, 48_000, 16_000)) assertEquals(expected, decode(audio(sequence, rate, invert = false, noise = 0f), rate), "at $rate") + } + + @Test fun decodesInvertedNoisySignal() { + for (rate in listOf(44_100, 48_000)) { + assertEquals(expected, decode(audio(sequence, rate, invert = true, noise = 0.08f), rate), "inverted at $rate") + assertEquals(expected, decode(audio(sequence, rate, invert = false, noise = 0.08f), rate, chunk = 97), "noisy at $rate") + } + } + + @Test fun fiveDigitTimersAndBadChecksums() { + val gen3 = packetBytes('S', "12345") // 1:23.45 + val corrupt = packetBytes('S', "112345").toMutableList().also { it[3] = '9'.code } + val got = decode(audio(listOf(gen3, corrupt, gen3), 44_100, invert = false, noise = 0f), 44_100) + assertEquals(listOf(StackmatPacket('S', 83_450, 5), StackmatPacket('S', 83_450, 5)), got) + } + + @Test fun silenceAndNoiseDecodeToNothing() { + val rng = Random(4) + assertTrue(decode(FloatArray(48_000) { (rng.nextFloat() - 0.5f) * 0.01f }, 48_000).isEmpty()) + assertTrue(decode(FloatArray(48_000) { rng.nextFloat() - 0.5f }, 48_000).isEmpty()) + } + + @Test fun sessionRecordsEachSolveOnce() { + val solves = mutableListOf<Long>() + val s = StackmatSession { solves += it } + fun p(c: Char, ms: Long) = s.packet(StackmatPacket(c, ms, 6)) + p('S', 9_990) // a time already on the display when we connect: not ours + p('I', 0); p('A', 0); p(' ', 100); p(' ', 5_000); p('S', 12_340); p('S', 12_340); p('C', 12_340) + p('I', 0); p('S', 8_000) // the running packets were lost entirely + assertEquals(listOf(12_340L, 8_000L), solves) + } +} |
