diff options
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() } |
