summaryrefslogtreecommitdiff
path: root/core
diff options
context:
space:
mode:
Diffstat (limited to 'core')
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt9
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/stackmat/Stackmat.kt120
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt6
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt83
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)
+ }
+}