diff options
Diffstat (limited to 'core')
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) + } +} |
