summaryrefslogtreecommitdiff
path: root/core/src/commonMain/kotlin/coffee
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 /core/src/commonMain/kotlin/coffee
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>
Diffstat (limited to 'core/src/commonMain/kotlin/coffee')
-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
3 files changed, 56 insertions, 4 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. */