summaryrefslogtreecommitdiff
path: root/core/src/commonTest/kotlin
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/commonTest/kotlin
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/commonTest/kotlin')
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt83
1 files changed, 83 insertions, 0 deletions
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)
+ }
+}