From 009525bd46edfc77068da212e36f38d410503f2c Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Fri, 13 Nov 2026 15:28:48 -0800 Subject: 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 -. 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 --- .../kotlin/coffee/liz/rookery/desktop/CubeCli.kt | 15 ++++-- .../main/kotlin/coffee/liz/rookery/desktop/Main.kt | 43 ++++++++++++++-- .../kotlin/coffee/liz/rookery/desktop/Shots.kt | 58 +++++++++++++++++++--- 3 files changed, 100 insertions(+), 16 deletions(-) (limited to 'desktop/src/main/kotlin') 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) { 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() 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() 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) = 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 = withContext(Dispatchers.IO) { - val key = config.braveApiKey ?: error("No Brave Search key on this machine.") + override suspend fun searchImages(query: String, key: String): List = 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 + 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? = 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 `: 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(extraBufferCapacity = 256) + fun shots(args: List) { 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) { 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() + override suspend fun searchImages(query: String, key: String) = emptyList() override suspend fun fetchBytes(url: String) = ByteArray(0) + override suspend fun clipboardImage(): ByteArray? = null override fun shrinkImage(bytes: ByteArray): Pair? = 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) { 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 } -- cgit v1.3