diff options
Diffstat (limited to 'core/src/commonMain/kotlin/coffee')
3 files changed, 135 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 |
