summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:41:08 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:41:08 -0800
commit2a9a3126a4955dffc40bc04ab88a5ccdb3da77c9 (patch)
treeb19e42dde8eccf6c653495bd103db75e78b19201
parent3afb04de43726d71580b626e856c68b6ed79984e (diff)
downloadrookery-2a9a3126a4955dffc40bc04ab88a5ccdb3da77c9.tar.gz
rookery-2a9a3126a4955dffc40bc04ab88a5ccdb3da77c9.zip
GAN: never stall on lost packets; fix packaged Bluetooth runtime
Live testing on a GANicE3 (Gen4) showed a move held for 32 s and a deliberate disconnect once 16 moves backed up, both waiting on a move-history reply that never came (inherited from gan-web-bluetooth). - Retry history requests every 300 ms while stalled; after 1.5 s release held moves in order, flag the first with Move.afterGap, and request facelets so consumers can resync. Never disconnect over a backlog; a driver tick runs every 150 ms under a shared lock. - Bundle jdk.security.auth (and the other modules Compose suggests) in the packaged runtime: dbus-java needs it to authenticate to BlueZ. - `--cube monitor`: print facelets only on change, mark gaps, and accept r (reset to solved) / f (refresh) on stdin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt11
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt46
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt3
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt36
-rw-r--r--desktop/build.gradle.kts7
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt23
6 files changed, 119 insertions, 7 deletions
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt
index c6991d4..2f824dc 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt
@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -93,6 +94,8 @@ class GanSmartCube internal constructor(
private val _events = MutableSharedFlow<SmartCubeEvent>(extraBufferCapacity = 1024, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val events: SharedFlow<SmartCubeEvent> = _events.asSharedFlow()
private val writeLock = Mutex()
+ /** Serializes driver state between notifications and the retry ticker. */
+ private val driverLock = Mutex()
private var closed = false
init {
@@ -100,10 +103,16 @@ class GanSmartCube internal constructor(
notifications.collect { raw ->
if (raw.size >= 16) {
val plain = encrypter.decrypt(raw)
- driver.handleStateEvent(this@GanSmartCube, plain).forEach { _events.emit(it) }
+ driverLock.withLock { driver.handleStateEvent(this@GanSmartCube, plain) }.forEach { _events.emit(it) }
}
}
}
+ scope.launch {
+ while (true) {
+ delay(150)
+ driverLock.withLock { driver.tick(this@GanSmartCube) }.forEach { _events.emit(it) }
+ }
+ }
scope.launch(start = CoroutineStart.UNDISPATCHED) {
conn.disconnected.first()
finish()
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt
index 1aef3cd..6f639e6 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt
@@ -17,6 +17,9 @@ interface GanRawConnection {
interface GanProtocolDriver {
fun createCommandMessage(command: GanCommand): ByteArray?
suspend fun handleStateEvent(conn: GanRawConnection, message: ByteArray): List<SmartCubeEvent>
+
+ /** Called every ~150 ms so drivers can retry or give up on things the cube never answered. */
+ suspend fun tick(conn: GanRawConnection): List<SmartCubeEvent> = emptyList()
}
private const val FACES = "URFDLB"
@@ -134,24 +137,58 @@ abstract class GanBufferedProtocolDriver(protected val clock: () -> Long) : GanP
}
}
+ /** When the buffer started waiting on a gap (null = not stalled), and when we last asked for history. */
+ private var stalledSince: Long? = null
+ private var lastHistoryRequest = 0L
+
protected suspend fun evictMoveBuffer(conn: GanRawConnection?): List<SmartCubeEvent> {
val evicted = mutableListOf<SmartCubeEvent>()
while (moveBuffer.isNotEmpty()) {
val head = moveBuffer.first()
val diff = if (lastSerial == -1) 1 else (head.serial - lastSerial) and 0xFF
if (diff > 1) {
- if (conn != null) requestMoveHistory(conn, head.serial, diff)
+ if (stalledSince == null) stalledSince = clock()
+ if (conn != null) { requestMoveHistory(conn, head.serial, diff); lastHistoryRequest = clock() }
break
} else {
evicted += moveBuffer.removeFirst()
lastSerial = head.serial
}
}
- // Something went wrong and the buffer no longer drains: drop the connection.
- if (conn != null && moveBuffer.size > 16) conn.disconnect()
+ if (moveBuffer.isEmpty()) stalledSince = null
+ // A big backlog means history isn't coming: stop waiting rather than hold moves hostage.
+ if (moveBuffer.size > MAX_BUFFERED) evicted += giveUp(conn)
return evicted
}
+ /**
+ * Release everything held behind a gap, in order. The first released move is marked [Move.afterGap]
+ * so consumers know moves are missing before it; we also ask for facelets so they can resync.
+ */
+ private suspend fun giveUp(conn: GanRawConnection?): List<SmartCubeEvent> {
+ val released = moveBuffer.mapIndexed { i, m -> if (i == 0) m.copy(afterGap = true) else m }
+ moveBuffer.clear()
+ released.lastOrNull()?.let { lastSerial = it.serial }
+ stalledSince = null
+ if (conn != null) createCommandMessage(GanCommand.REQUEST_FACELETS)?.let { msg ->
+ try { conn.sendCommandMessage(msg) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e }
+ }
+ return released
+ }
+
+ /** Retry unanswered history requests, and give up on a gap that history can't fill. */
+ override suspend fun tick(conn: GanRawConnection): List<SmartCubeEvent> {
+ val since = stalledSince ?: return emptyList()
+ val now = clock()
+ if (now - since >= GIVE_UP_MS) return giveUp(conn)
+ if (now - lastHistoryRequest >= RETRY_MS) {
+ val head = moveBuffer.firstOrNull() ?: return emptyList()
+ requestMoveHistory(conn, head.serial, (head.serial - lastSerial) and 0xFF)
+ lastHistoryRequest = now
+ }
+ return emptyList()
+ }
+
private fun isSerialInRange(start: Int, end: Int, serial: Int, closedStart: Boolean = false, closedEnd: Boolean = false) =
((end - start) and 0xFF) >= ((serial - start) and 0xFF) &&
(closedStart || ((start - serial) and 0xFF) > 0) &&
@@ -208,6 +245,9 @@ abstract class GanBufferedProtocolDriver(protected val clock: () -> Long) : GanP
}
private companion object {
+ const val RETRY_MS = 300L
+ const val GIVE_UP_MS = 1_500L
+ const val MAX_BUFFERED = 16
val LIVE_FACE_BITS = listOf(2, 32, 8, 1, 16, 4)
val HISTORY_FACE_BITS = listOf(1, 5, 3, 0, 4, 2)
}
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 747a391..f0e9cee 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt
@@ -18,6 +18,8 @@ sealed interface SmartCubeEvent {
* [hostTimestampMs] is the host's monotonic clock when the notification arrived.
* [recovered] marks moves that were missed live and filled in from the cube's move history,
* whose host timestamp is therefore not meaningful.
+ * [afterGap] marks the first move released after giving up on moves the cube never re-sent:
+ * some moves before it are missing, so trust the next [Facelets] over replaying moves.
*/
data class Move(
val move: String,
@@ -25,6 +27,7 @@ sealed interface SmartCubeEvent {
val hostTimestampMs: Long,
val serial: Int = -1,
val recovered: Boolean = false,
+ val afterGap: Boolean = false,
) : SmartCubeEvent
/** Kociemba-style 54-char facelets in U R F D L B order. */
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt
index 5e45af3..3ba2417 100644
--- a/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt
@@ -197,6 +197,42 @@ class GanProtocolTest {
)
}
+ @Test fun gen4RetriesHistoryThenGivesUpInsteadOfHanging() = runTest {
+ // The failure seen live on a GANicE3: a move after a lost packet waited until the next turn.
+ val d = GanGen4ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ d.handleStateEvent(c, Packets.gen4Facelets(10, SOLVED_STATE))
+ d.handleStateEvent(c, Packets.gen4Move(11, 4, 1, 100))
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen4Move(13, 5, 0, 155))) // 12 lost
+ assertEquals(1, c.sent.size)
+
+ now += 100; assertEquals(emptyList(), d.tick(c)); assertEquals(1, c.sent.size) // too soon to retry
+ now += 250; assertEquals(emptyList(), d.tick(c)); assertEquals(2, c.sent.size) // retried
+ assertEquals(0xD1.toByte(), c.sent.last()[0])
+
+ // History never comes: after 1.5 s the held move is released, flagged, and facelets requested.
+ now += 1_200
+ val released = d.tick(c)
+ assertEquals(listOf(Move("B", 155, 1_000, 13, afterGap = true)), released)
+ assertContentEquals(byteArrayOf(0xDD.toByte(), 0x04, 0x00, 0xED.toByte(), 0x00, 0x00) + ByteArray(14), c.sent.last())
+ assertEquals(0, c.disconnects)
+
+ // Live moves flow again immediately afterwards.
+ assertEquals(listOf("U"), d.handleStateEvent(c, Packets.gen4Move(14, 0, 0, 900)).map { (it as Move).move })
+ assertEquals(emptyList(), d.tick(c))
+ }
+
+ @Test fun gen4BigBacklogIsReleasedNotDisconnected() = runTest {
+ val d = GanGen4ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ d.handleStateEvent(c, Packets.gen4Facelets(10, SOLVED_STATE))
+ d.handleStateEvent(c, Packets.gen4Move(11, 0, 0, 1))
+ val out = (13..30).flatMap { d.handleStateEvent(c, Packets.gen4Move(it, 0, 0, it.toLong())) }
+ assertEquals((13..30).toList(), out.map { (it as Move).serial }) // 13-29 released, then 30 flows live
+ assertTrue((out.first() as Move).afterGap)
+ assertEquals(0, c.disconnects)
+ }
+
@Test fun gen4FaceletsTriggerHistoryWhenIdle() = runTest {
val d = GanGen4ProtocolDriver(clock)
val c = RecordingRawConnection()
diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts
index d143c5f..e0fd832 100644
--- a/desktop/build.gradle.kts
+++ b/desktop/build.gradle.kts
@@ -19,6 +19,8 @@ compose.desktop {
mainClass = "coffee.liz.rookery.desktop.MainKt"
jvmArgs += listOf("-Xmx256m", "-XX:+UseSerialGC")
nativeDistributions {
+ // From `./gradlew :desktop:suggestRuntimeModules`; jdk.security.auth is needed by dbus-java (Bluetooth).
+ modules("java.instrument", "jdk.security.auth", "jdk.unsupported")
targetFormats(TargetFormat.AppImage, TargetFormat.Deb)
packageName = "rookery"
packageVersion = "0.1.0"
@@ -27,4 +29,7 @@ compose.desktop {
}
// `./gradlew :desktop:run --args="--import file.txt"` passes args through; env lets tests point at a scratch home.
-tasks.withType<JavaExec>().configureEach { environment.putAll(System.getenv().filterKeys { it.startsWith("XDG_") || it.startsWith("ROOKERY_") }) }
+tasks.withType<JavaExec>().configureEach {
+ environment.putAll(System.getenv().filterKeys { it.startsWith("XDG_") || it.startsWith("ROOKERY_") })
+ standardInput = System.`in` // so `--cube monitor` can take commands
+}
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
index b183420..171a6d9 100644
--- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
@@ -47,6 +47,8 @@ private fun scan(seconds: Long, all: Boolean) = BlueZTransport().use { transport
}
}
+private const val SOLVED_FACELETS = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB"
+
private fun monitor(mac: String) = BlueZTransport().use { transport ->
runBlocking {
println("Connecting to $mac...")
@@ -55,14 +57,20 @@ private fun monitor(mac: String) = BlueZTransport().use { transport ->
val done = CompletableDeferred<Unit>()
Runtime.getRuntime().addShutdownHook(Thread { runBlocking { withTimeoutOrNull(2000) { cube.disconnect() } } })
var firstMoveHost: Long? = null
+ var lastFacelets: String? = null
val collector = launch(start = CoroutineStart.UNDISPATCHED) {
cube.events.collect { e ->
when (e) {
is SmartCubeEvent.Move -> {
val t0 = firstMoveHost ?: e.hostTimestampMs.also { firstMoveHost = it }
+ if (e.afterGap) println(" … moves missing here (the cube never re-sent them); resyncing from facelets")
println("move %-3s host +%6d ms cube %s%s".format(e.move, e.hostTimestampMs - t0, e.cubeTimestampMs?.let { "$it ms" } ?: "-", if (e.recovered) " (recovered)" else ""))
}
- is SmartCubeEvent.Facelets -> println("facelets ${e.facelets}")
+ // The cube repeats facelets periodically; only show changes.
+ is SmartCubeEvent.Facelets -> if (e.facelets != lastFacelets) {
+ lastFacelets = e.facelets
+ println("facelets ${e.facelets}" + if (e.facelets == SOLVED_FACELETS) " (solved)" else "")
+ }
is SmartCubeEvent.Battery -> println("battery ${e.percent}%")
is SmartCubeEvent.Hardware -> println("hardware ${e.name} hw ${e.hardwareVersion} sw ${e.softwareVersion}" + (e.productDate?.let { " made $it" } ?: "") + if (e.gyroSupported) " gyro" else "")
is SmartCubeEvent.Gyro -> Unit // too chatty to print
@@ -73,7 +81,18 @@ private fun monitor(mac: String) = BlueZTransport().use { transport ->
cube.requestHardware(); delay(300)
cube.requestBattery(); delay(300)
cube.requestFacelets()
- println("Streaming moves; Ctrl-C to quit.")
+ println("Streaming moves. Type r+Enter when the cube is physically solved to resync it, f+Enter to re-read state, Ctrl-C to quit.")
+ // stdin commands, read on a plain thread so the coroutine side never blocks
+ val scope = this
+ Thread {
+ while (true) {
+ when (readlnOrNull()?.trim()) {
+ null -> break
+ "r" -> scope.launch { cube.resetToSolved(); delay(200); cube.requestFacelets(); println("told the cube it's solved") }
+ "f" -> scope.launch { lastFacelets = null; cube.requestFacelets() }
+ }
+ }
+ }.apply { isDaemon = true }.start()
done.await()
collector.cancel()
}