summaryrefslogtreecommitdiff
path: root/desktop/src/main/kotlin/coffee
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 15:28:48 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 15:37:09 -0800
commit009525bd46edfc77068da212e36f38d410503f2c (patch)
treef042565bb01d070f626213b72865800ec0dbf576 /desktop/src/main/kotlin/coffee
parent60426ce45763d0a4a5572db6926e8e677cbc0dc0 (diff)
downloadrookery-009525bd46edfc77068da212e36f38d410503f2c.tar.gz
rookery-009525bd46edfc77068da212e36f38d410503f2c.zip
Smart-cube timing, macOS Bluetooth, Android app, stackmat, pairing UI
- Timing with a smart cube: the scramble stays put and is marked off move by move (ScrambleTracker); a stray turn gets the shortest path to the same scrambled state from the two-phase solver, kept until the next stray turn. Wide moves in BLD scrambles become face turns. Once scrambled, inspection (or blind memo) starts by itself after a configurable delay; the first turn starts the clock and the solved cube stops it. The cube's own state reports are authoritative, so a move lost over Bluetooth can't keep the timer running. Blind solves show a MEMO/EXEC annunciator and store the memo split. A live view of the cube sits beside the timer. - Bluetooth through Kable on macOS and Windows (Linux keeps BlueZ) and on Android; the cube's MAC is read from its advertisement where the platform hides it. Scan/pair/connect/forget UI in settings. - Android app (:android): core and ui gain Android targets, with a jvmShared source set for code desktop and Android share. Android host for storage, sound, file import, images, Bluetooth and audio. Phone layouts: stacked panes and a full-screen timer with touch start. Scramble drawings (SVG) are desktop-only for now. - Stackmat: audio decoder and session; the mat can drive the timer. - Timer readout always shows the tens-of-seconds digit. - UI copy reduced to plain labels; marquee, snow, pointer-following and penguin speech removed. Brave key can be entered in settings. - Phone timer: one-line header, session picker and averages on one line, solves/stats in the footer; sessions can be renamed and deleted and are named <event>-<date>. Display mode fills the screen (cut-out included), fits the scramble on one line, shows the live cube, and the screen stays awake. - Readout: digits keep one size (inspection included), whole seconds while running unless set otherwise; previous scramble shown; the scramble no longer blanks while the next is generated. - Fixes: stackmat and smart cube no longer both drive the timer; a solved cube or a sub-quarter-second tap can't record a solve. - Blind memo indicator is an LCD penguin pacing the glass. - Android: system-bar insets, folder picker (all files access), Brave key synced with the data. - Algorithm trainer: PLL and OLL from J Perm's sheets, each algorithm verified by simulation; case plans, your choice of algorithm (or your own, checked against the case), new/learning/known, and a drill weighted towards weak cases. - Pair cards show the pair on a 3D see-through cube with an arrow; the memo cube can be dragged between targets. - CI image installs the Android SDK and runs core tests + desktop packaging (untested). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Diffstat (limited to 'desktop/src/main/kotlin/coffee')
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt15
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt43
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt58
3 files changed, 100 insertions, 16 deletions
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 171a6d9..7c9b65f 100644
--- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
@@ -1,7 +1,6 @@
package coffee.liz.rookery.desktop
import coffee.liz.rookery.bt.BleAdvertisement
-import coffee.liz.rookery.bt.BlueZTransport
import coffee.liz.rookery.bt.GanCubeConnector
import coffee.liz.rookery.bt.GanSmartCube
import coffee.liz.rookery.bt.JvmAes128
@@ -9,6 +8,7 @@ import coffee.liz.rookery.bt.SmartCubeEvent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
@@ -28,7 +28,7 @@ fun cubeCli(args: List<String>) {
exitProcess(0)
}
-private fun scan(seconds: Long, all: Boolean) = BlueZTransport().use { transport ->
+private fun scan(seconds: Long, all: Boolean) = bleTransport().let { transport ->
println("Scanning for ${if (all) "BLE devices" else "GAN cubes"} for $seconds s...")
val seen = LinkedHashMap<String, BleAdvertisement>()
runBlocking {
@@ -49,10 +49,17 @@ private fun scan(seconds: Long, all: Boolean) = BlueZTransport().use { transport
private const val SOLVED_FACELETS = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB"
-private fun monitor(mac: String) = BlueZTransport().use { transport ->
+private fun monitor(mac: String) = bleTransport().let { transport ->
runBlocking {
println("Connecting to $mac...")
- val cube = GanCubeConnector(transport, JvmAes128).connect(this, mac)
+ // Where the platform hides MACs (macOS), find the cube by the MAC inside its advertisement.
+ val address = if (transport is coffee.liz.rookery.bt.KableTransport) {
+ println("Listening for it (turn the cube to wake it)...")
+ withTimeoutOrNull(20_000) {
+ transport.scan().first { coffee.liz.rookery.bt.GanDefinitions.macFromManufacturerData(it.manufacturerData)?.equals(mac, true) == true || it.address.equals(mac, true) }
+ }?.address ?: run { println("Didn't hear a cube with that MAC."); return@runBlocking }
+ } else mac
+ val cube = GanCubeConnector(transport, JvmAes128).connect(this, address, mac)
println("Connected: ${cube.name ?: "?"} (${(cube as? GanSmartCube)?.generation ?: "?"})")
val done = CompletableDeferred<Unit>()
Runtime.getRuntime().addShutdownHook(Thread { runBlocking { withTimeoutOrNull(2000) { cube.disconnect() } } })
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
index 085ad9a..12a856a 100644
--- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
@@ -75,6 +75,10 @@ class LocalConfig {
(System.getenv("XDG_DATA_HOME")?.let { Paths.get(it) } ?: Paths.get(System.getProperty("user.home"), ".local", "share")).resolve("rookery")
}
+/** Linux talks to BlueZ directly (tested against a real cube); macOS and Windows go through Kable. `ROOKERY_BLE=kable` forces Kable. */
+fun bleTransport(): coffee.liz.rookery.bt.BleTransport =
+ if (System.getProperty("os.name").lowercase().contains("linux") && System.getenv("ROOKERY_BLE") != "kable") BlueZTransport() else coffee.liz.rookery.bt.KableTransport()
+
class DesktopHost(private val config: LocalConfig) : Host {
override fun playChime(notes: List<Chime.Note>) = ChimePlayer.play(notes)
@@ -110,6 +114,7 @@ class DesktopHost(private val config: LocalConfig) : Host {
override fun setSyncFolder(path: String) { config.syncFolder = path }
override val deviceId: String get() = config.deviceId
override val braveApiKey: String? get() = config.braveApiKey
+ override fun setBraveApiKey(key: String?) = config.set("braveApiKey", key)
override val touchInput: Boolean get() = false
private val http: java.net.http.HttpClient by lazy {
@@ -127,8 +132,7 @@ class DesktopHost(private val config: LocalConfig) : Host {
return res.body().use { it.readNBytes(MAX_DOWNLOAD + 1) }.also { check(it.size <= MAX_DOWNLOAD) { "that file is too large" } }
}
- override suspend fun searchImages(query: String): List<ImageHit> = withContext(Dispatchers.IO) {
- val key = config.braveApiKey ?: error("No Brave Search key on this machine.")
+ override suspend fun searchImages(query: String, key: String): List<ImageHit> = withContext(Dispatchers.IO) {
val body = try {
get(BraveImages.url(query), mapOf("Accept" to "application/json", BraveImages.KEY_HEADER to key)).decodeToString()
} catch (e: java.io.IOException) { error("couldn't reach Brave (${e.message ?: "offline?"}).") }
@@ -137,6 +141,35 @@ class DesktopHost(private val config: LocalConfig) : Host {
override suspend fun fetchBytes(url: String): ByteArray = withContext(Dispatchers.IO) { get(url) }
+ /** Picture bytes from the clipboard: an image that was pasted, else a picture file that was copied. */
+ override suspend fun clipboardImage(): ByteArray? = withContext(Dispatchers.Main) {
+ val contents = runCatching { java.awt.Toolkit.getDefaultToolkit().systemClipboard.getContents(null) }.getOrNull() ?: return@withContext null
+ val flavors = runCatching { contents.transferDataFlavors }.getOrNull() ?: return@withContext null
+ when {
+ flavors.contains(java.awt.datatransfer.DataFlavor.imageFlavor) -> withContext(Dispatchers.IO) {
+ // a pasted image arrives as an AWT Image; re-encode it as PNG so shrinkImage can read it
+ runCatching {
+ val img = contents.getTransferData(java.awt.datatransfer.DataFlavor.imageFlavor) as java.awt.Image
+ val w = img.getWidth(null); val h = img.getHeight(null)
+ if (w < 1 || h < 1) return@runCatching null
+ val bmp = java.awt.image.BufferedImage(w, h, java.awt.image.BufferedImage.TYPE_INT_ARGB)
+ val g = bmp.createGraphics()
+ g.drawImage(img, 0, 0, null); g.dispose()
+ java.io.ByteArrayOutputStream().use { out -> javax.imageio.ImageIO.write(bmp, "png", out); out.toByteArray() }
+ }.getOrNull()
+ }
+ flavors.contains(java.awt.datatransfer.DataFlavor.javaFileListFlavor) -> withContext(Dispatchers.IO) {
+ runCatching {
+ @Suppress("UNCHECKED_CAST")
+ val files = contents.getTransferData(java.awt.datatransfer.DataFlavor.javaFileListFlavor) as List<java.io.File>
+ val exts = listOf("jpg", "jpeg", "png", "webp", "gif")
+ files.firstOrNull { it.extension.lowercase() in exts }?.readBytes()
+ }.getOrNull()
+ }
+ else -> null
+ }
+ }
+
/** Fit inside 640px and re-encode as WebP, which is what gets synced. */
override fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? = runCatching {
val img = org.jetbrains.skia.Image.makeFromEncoded(bytes)
@@ -151,9 +184,9 @@ class DesktopHost(private val config: LocalConfig) : Host {
data.bytes to "webp"
}.getOrNull()
- /** Bluetooth goes through BlueZ, so Linux only for now. */
- override val cubes: CubeBackend? = if (!System.getProperty("os.name").lowercase().contains("linux")) null else object : CubeBackend {
- private val transport by lazy { BlueZTransport() }
+ /** Linux talks to BlueZ directly (tested against a real cube); macOS and Windows go through Kable. */
+ override val cubes: CubeBackend? = object : CubeBackend {
+ private val transport by lazy { bleTransport() }
override fun scan() = transport.scan().filter { GanCubeConnector.isGanCube(it) }
override suspend fun connect(scope: CoroutineScope, address: String, mac: String) = GanCubeConnector(transport, JvmAes128).connect(scope, address, mac)
}
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt
index 6a00c24..1846c46 100644
--- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt
@@ -29,6 +29,8 @@ import kotlin.random.Random
* `rookery --shots <dir>`: draws each screen offscreen with made-up data and writes PNGs. A development
* aid for looking at the UI without a window (or anyone's real data) involved.
*/
+val fakeCubeEvents = kotlinx.coroutines.flow.MutableSharedFlow<coffee.liz.rookery.bt.SmartCubeEvent>(extraBufferCapacity = 256)
+
fun shots(args: List<String>) {
val out = File(args.firstOrNull() ?: "shots").apply { mkdirs() }
val repo = Repo(Store("shots", MemoryLogStorage(), MemoryBlobStore(), System::currentTimeMillis))
@@ -52,20 +54,18 @@ fun shots(args: List<String>) {
override fun setSyncFolder(path: String) {}
override val deviceId = "sample-device"
override val braveApiKey: String? = "x"
+ override fun setBraveApiKey(key: String?) {}
override val touchInput = false
- override suspend fun searchImages(query: String) = emptyList<ImageHit>()
+ override suspend fun searchImages(query: String, key: String) = emptyList<ImageHit>()
override suspend fun fetchBytes(url: String) = ByteArray(0)
+ override suspend fun clipboardImage(): ByteArray? = null
override fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? = null
override fun setFullscreen(on: Boolean) {}
override val cubes = object : coffee.liz.rookery.bt.CubeBackend {
override fun scan() = kotlinx.coroutines.flow.flowOf(coffee.liz.rookery.bt.BleAdvertisement("AA:BB:CC:00:11:22", "GANicE3_1122", -58))
override suspend fun connect(scope: CoroutineScope, address: String, mac: String) = object : coffee.liz.rookery.bt.SmartCube {
override val name = "GANicE3_1122"; override val address = address
- override val events = kotlinx.coroutines.flow.flow {
- emit(coffee.liz.rookery.bt.SmartCubeEvent.Battery(87))
- emit(coffee.liz.rookery.bt.SmartCubeEvent.Facelets("UUUUUUFFFRRRRRRRRRFFDFFDFFDDDDDDDBBBLLLLLLLLLUBBUBBUBB"))
- emit(coffee.liz.rookery.bt.SmartCubeEvent.Move("F", null, 0)); kotlinx.coroutines.awaitCancellation()
- }
+ override val events = fakeCubeEvents
override suspend fun requestFacelets() {}; override suspend fun requestBattery() {}; override suspend fun requestHardware() {}
override suspend fun resetToSolved() {}; override suspend fun disconnect() {}
}
@@ -96,9 +96,53 @@ fun shots(args: List<String>) {
shoot("party", settle = 1800) { model.testParty() }
model.party = null
for (mode in BldMode.entries) shoot("bld-${mode.name.lowercase()}", settle = 4000) { model.tab = Tab.BLD; model.bldMode = mode }
+ // phone-sized renders
+ shoot("phone-timer", w = 411, h = 890) { model.tab = Tab.TIMER }
+ shoot("phone-sessions", w = 411, h = 890, settle = 800) { model.timerPane = "solves" }
+ model.timerPane = "timer"
+ shoot("phone-targets", w = 411, h = 890, settle = 3000) { model.tab = Tab.BLD; model.bldMode = BldMode.TARGETS }
+ shoot("phone-display-landscape", w = 890, h = 411) { model.changeDisplayMode(true) }
+ model.changeDisplayMode(false)
+ shoot("algs") { model.tab = Tab.ALGS }
+ shoot("phone-algs", w = 411, h = 890) { model.tab = Tab.ALGS }
shoot("settings") { model.tab = Tab.SETTINGS; model.cube.scan(1) }
- shoot("settings-cube") { model.cube.connect("AA:BB:CC:00:11:22", "GANicE3_1122"); model.stackmat.change(true) }
+ shoot("settings-cube") {
+ model.cube.connect("AA:BB:CC:00:11:22", "GANicE3_1122"); model.stackmat.change(true)
+ Thread.sleep(300)
+ fakeCubeEvents.tryEmit(coffee.liz.rookery.bt.SmartCubeEvent.Battery(87))
+ fakeCubeEvents.tryEmit(coffee.liz.rookery.bt.SmartCubeEvent.Facelets(coffee.liz.rookery.puzzle.Cube3x3.SOLVED))
+ }
model.stackmat.change(false)
+
+ // Walk a whole smart-cube solve through the model with the fake cube.
+ fun turn(m: String) { fakeCubeEvents.tryEmit(coffee.liz.rookery.bt.SmartCubeEvent.Move(m, null, 0)); Thread.sleep(60) }
+ fun check(what: String, ok: Boolean) = println((if (ok) "ok " else "FAIL ") + what)
+ model.changeAutoStart(0)
+ for ((label, ev) in listOf("3x3" to Event.E333, "3bld" to Event.E333BF)) {
+ model.createSession(ev); Thread.sleep(2500)
+ val plan0 = model.smartPlan
+ check("$label: plan follows the scramble (${plan0.size} moves)", model.smartKnown && plan0.isNotEmpty() && !model.smartCorrected)
+ turn(plan0[0]); check("$label: first move consumed", model.smartPlan.size == plan0.size - 1 || model.smartPlan[0][0] == plan0[0][0])
+ val next = model.smartPlan[0][0]
+ val wrong = "URFDLB".first { it != next && "URFDLB"[("URFDLB".indexOf(it) + 3) % 6] != next }.toString()
+ turn(wrong); check("$label: stray move gives a fix (${model.smartPlan.size} moves)", model.smartCorrected && model.smartPlan.isNotEmpty())
+ if (label == "3x3") shoot("timer-smart", settle = 600) { model.tab = Tab.TIMER }
+ for (mv in model.smartPlan.toList()) turn(mv)
+ check("$label: scrambled", model.smartScrambled)
+ Thread.sleep(700)
+ val st = model.timer
+ if (ev.blind) {
+ check("$label: memo started by itself", st is coffee.liz.rookery.timer.TimerMachine.State.Running && model.memoMs == null)
+ shoot("timer-memo", settle = 900) { }
+ } else check("$label: inspection started by itself", st is coffee.liz.rookery.timer.TimerMachine.State.Inspecting)
+ val before = repo.solves().size
+ val solution = TnoodleScrambler().solve3x3(coffee.liz.rookery.puzzle.Cube3x3.facelets(coffee.liz.rookery.puzzle.NxnCube(3).apply(coffee.liz.rookery.puzzle.Cube3x3.faceTurns(model.scramble).joinToString(" "))))!!.split(" ")
+ for (mv in solution) turn(mv)
+ Thread.sleep(500)
+ val solve = repo.solves().maxByOrNull { it.at }
+ check("$label: solve recorded when the cube was solved", repo.solves().size == before + 1 && solve?.penalty == Penalty.NONE && solve.moves?.split(' ')?.size == solution.size)
+ if (ev.blind) check("$label: memo split stored (${solve?.memoMs} ms)", solve?.memoMs != null)
+ }
shoot("import") { model.tab = Tab.IMPORT }
model.setTheme(ThemeMode.LIGHT)
shoot("timer-paper") { model.tab = Tab.TIMER }