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/StackmatTest.kt | 83 ++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt (limited to 'core/src/commonTest/kotlin/coffee') 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 = + 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>, rate: Int, invert: Boolean, noise: Float, rng: Random = Random(1)): FloatArray { + val bits = ArrayList() + 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 { + val got = mutableListOf() + 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() + 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) + } +} -- cgit v1.3