summaryrefslogtreecommitdiff
path: root/core/src/commonMain/kotlin/coffee/liz
diff options
context:
space:
mode:
Diffstat (limited to 'core/src/commonMain/kotlin/coffee/liz')
-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
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