From 3afb04de43726d71580b626e856c68b6ed79984e Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Thu, 12 Nov 2026 21:11:08 -0800 Subject: Rookery: penguin-themed cubing toolkit, phases 0-1 and GAN BLE Kotlin Multiplatform + Compose desktop app. - Sync: per-device append-only op logs (HLC-stamped LWW) in a plain folder, safe under Syncthing/Drive; content-addressed image blobs. - Timer: WCA inspection, +2/DNF, solve table, WCA-correct aoN/moN, per-event lifetime PBs with a pixel-penguin party and synthesized 8-bit chime. Mascot sprite from liz.coffee. - Scrambles for all WCA events + FTO via tnoodle-lib, with previews. - Importers: csTimer exports (incl. smart-cube reconstructions and cube MACs) and the Letter Pair Gym prototype (words, notes, images, history, practice stats). Idempotent; headless `--import`. - Letter-pair grid with miss heatmap, glance lookup and editor. - 3D puzzle engine (polyhedron + cut planes) and Canvas renderer: NxN 2-7, Pyraminx, Skewb, verified sticker-for-sticker against tnoodle; 3D replay of smart-cube solves. - GAN Gen2/3/4 smart-cube protocol (ported from gan-web-bluetooth, MIT) with a BlueZ D-Bus transport and `--cube scan|monitor` CLI. Not yet tested against hardware. - lizCI pipeline (.ci) building a test+package Docker target. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/coffee/liz/rookery/desktop/CubeCli.kt | 80 +++++++++++ .../main/kotlin/coffee/liz/rookery/desktop/Main.kt | 147 +++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt create mode 100644 desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt (limited to 'desktop/src/main') diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt new file mode 100644 index 0000000..b183420 --- /dev/null +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt @@ -0,0 +1,80 @@ +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 +import coffee.liz.rookery.bt.SmartCubeEvent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.system.exitProcess + +/** + * `rookery --cube scan [seconds] [--all]` lists nearby GAN smart cubes (or every BLE device) for ~10 s. + * `rookery --cube monitor ` connects and streams moves until Ctrl-C. + */ +fun cubeCli(args: List) { + val usage = "usage: --cube scan [seconds] [--all] | --cube monitor " + when (args.firstOrNull()) { + "scan" -> scan(args.getOrNull(1)?.toLongOrNull() ?: 10, all = "--all" in args) + "monitor" -> monitor(args.getOrNull(1) ?: run { System.err.println(usage); exitProcess(2) }) + else -> { System.err.println(usage); exitProcess(2) } + } + exitProcess(0) +} + +private fun scan(seconds: Long, all: Boolean) = BlueZTransport().use { transport -> + println("Scanning for ${if (all) "BLE devices" else "GAN cubes"} for $seconds s...") + val seen = LinkedHashMap() + runBlocking { + withTimeoutOrNull(seconds * 1000) { + transport.scan().collect { adv -> + if (!all && !GanCubeConnector.isGanCube(adv)) return@collect + if (adv.address !in seen) println(" found ${adv.name ?: "?"} ${adv.address} rssi ${adv.rssi ?: "?"}") + seen[adv.address] = adv + } + } + } + if (seen.isEmpty()) println(if (all) "No BLE devices found." else "No GAN cubes found (turn the cube a little to wake it).") + else { + println("${seen.size} ${if (all) "device" else "cube"}(s):") + seen.values.forEach { println(" ${it.name ?: "?"}\t${it.address}\trssi ${it.rssi ?: "?"}") } + } +} + +private fun monitor(mac: String) = BlueZTransport().use { transport -> + runBlocking { + println("Connecting to $mac...") + val cube = GanCubeConnector(transport, JvmAes128).connect(this, mac) + println("Connected: ${cube.name ?: "?"} (${(cube as? GanSmartCube)?.generation ?: "?"})") + val done = CompletableDeferred() + Runtime.getRuntime().addShutdownHook(Thread { runBlocking { withTimeoutOrNull(2000) { cube.disconnect() } } }) + var firstMoveHost: Long? = null + val collector = launch(start = CoroutineStart.UNDISPATCHED) { + cube.events.collect { e -> + when (e) { + is SmartCubeEvent.Move -> { + val t0 = firstMoveHost ?: e.hostTimestampMs.also { firstMoveHost = it } + 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}") + 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 + SmartCubeEvent.Disconnected -> { println("disconnected"); done.complete(Unit) } + } + } + } + cube.requestHardware(); delay(300) + cube.requestBattery(); delay(300) + cube.requestFacelets() + println("Streaming moves; Ctrl-C to quit.") + done.await() + collector.cancel() + } +} diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt new file mode 100644 index 0000000..60d7443 --- /dev/null +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt @@ -0,0 +1,147 @@ +package coffee.liz.rookery.desktop + +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import coffee.liz.rookery.audio.Chime +import coffee.liz.rookery.audio.ChimePlayer +import coffee.liz.rookery.model.Event +import coffee.liz.rookery.scramble.TnoodleScrambler +import coffee.liz.rookery.store.FolderBlobStore +import coffee.liz.rookery.store.FolderLogStorage +import coffee.liz.rookery.store.Repo +import coffee.liz.rookery.store.Store +import coffee.liz.rookery.ui.AppModel +import coffee.liz.rookery.ui.Host +import coffee.liz.rookery.ui.RookeryApp +import coffee.liz.rookery.ui.handleTimerKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.FileDialog +import java.awt.Frame +import java.nio.file.Path +import java.nio.file.Paths +import java.util.Properties +import java.util.zip.ZipFile +import javax.swing.JFileChooser +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.inputStream +import kotlin.io.path.outputStream +import kotlin.io.path.readBytes + +/** Per-machine settings (never synced): which folder to sync, and this device's id. */ +class LocalConfig { + private val dir: Path = (System.getenv("XDG_CONFIG_HOME")?.let { Paths.get(it) } ?: Paths.get(System.getProperty("user.home"), ".config")).resolve("rookery") + private val file = dir.resolve("config.properties") + private val props = Properties().apply { if (file.exists()) file.inputStream().use { load(it) } } + + private fun save() { dir.createDirectories(); file.outputStream().use { props.store(it, "rookery local config") } } + + val deviceId: String = props.getProperty("deviceId") ?: run { + val host = runCatching { java.net.InetAddress.getLocalHost().hostName }.getOrNull()?.filter { it.isLetterOrDigit() || it == '-' }?.take(24) ?: "desktop" + "$host-${coffee.liz.rookery.randomId().take(6)}".also { props.setProperty("deviceId", it); save() } + } + + var syncFolder: String + get() = props.getProperty("syncFolder") ?: defaultData().resolve("sync").toString() + set(v) { props.setProperty("syncFolder", v); save() } + + private fun defaultData(): Path = + (System.getenv("XDG_DATA_HOME")?.let { Paths.get(it) } ?: Paths.get(System.getProperty("user.home"), ".local", "share")).resolve("rookery") +} + +class DesktopHost(private val config: LocalConfig) : Host { + override fun playChime(notes: List) = ChimePlayer.play(notes) + + override suspend fun pickFile(title: String, extensions: List): String? = withContext(Dispatchers.Main) { + val d = FileDialog(null as Frame?, title, FileDialog.LOAD) + d.setFilenameFilter { _, name -> extensions.any { name.lowercase().endsWith(".$it") } } + d.isVisible = true + d.file?.let { Paths.get(d.directory, it).toString() } + } + + override suspend fun pickFolder(title: String): String? = withContext(Dispatchers.Main) { + val c = JFileChooser(config.syncFolder).apply { dialogTitle = title; fileSelectionMode = JFileChooser.DIRECTORIES_ONLY } + if (c.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) c.selectedFile.absolutePath else null + } + + override suspend fun readFile(path: String): ByteArray = withContext(Dispatchers.IO) { Paths.get(path).readBytes() } + + override suspend fun readImport(path: String, mainEntry: String?): Pair ByteArray?> = withContext(Dispatchers.IO) { + if (!path.lowercase().endsWith(".zip")) { + val dir = Paths.get(path).parent + Paths.get(path).readBytes().decodeToString() to { rel: String -> dir.resolve(rel).normalize().takeIf { it.startsWith(dir) && it.exists() }?.readBytes() } + } else { + // Read everything up front so the loader works after the zip is closed. + val entries = ZipFile(path).use { z -> z.entries().toList().filter { !it.isDirectory }.associate { it.name to z.getInputStream(it).readBytes() } } + val main = entries.entries.firstOrNull { it.key == mainEntry || it.key.endsWith("/$mainEntry") } + ?: error("no $mainEntry in ${Paths.get(path).fileName}") + val prefix = main.key.removeSuffix(mainEntry ?: "") + main.value.decodeToString() to { rel: String -> entries[prefix + rel] } + } + } + + override val syncFolder: String get() = config.syncFolder + override fun setSyncFolder(path: String) { config.syncFolder = path } + override val deviceId: String get() = config.deviceId +} + +fun main(args: Array) { + if (args.firstOrNull() == "--import") return headlessImport(args.drop(1)) + if (args.firstOrNull() == "--cube") return cubeCli(args.drop(1)) + System.setProperty("skiko.renderApi", System.getProperty("skiko.renderApi") ?: "OPENGL") + val config = LocalConfig() + val root = Paths.get(config.syncFolder).also { it.createDirectories() } + val store = Store(config.deviceId, FolderLogStorage(root, config.deviceId), FolderBlobStore(root), System::currentTimeMillis) + val repo = Repo(store) + val scrambler = TnoodleScrambler().apply { warm(Event.E333) } + + application { + val state = rememberWindowState(size = DpSize(1280.dp, 820.dp)) + val scope = rememberCoroutineScope() + val model = remember { + AppModel(repo, scrambler, DesktopHost(config), scope).apply { + // dev hooks for screenshots: ROOKERY_TAB=PAIRS, ROOKERY_PARTY=1, ROOKERY_REPLAY=1, ROOKERY_EVENT=pyram + System.getenv("ROOKERY_TAB")?.let { t -> coffee.liz.rookery.ui.Tab.entries.firstOrNull { it.name == t }?.let { tab = it } } + System.getenv("ROOKERY_EVENT")?.let { id -> Event.byId(id)?.let { e -> scope.launch { kotlinx.coroutines.delay(1500); createSession(e) } } } + if (System.getenv("ROOKERY_REPLAY") != null) scope.launch { + kotlinx.coroutines.delay(4000) + derived.sessionSolves.lastOrNull { it.moves != null }?.let { selectedSolveId = it.id; startReplay(it) } + } + if (System.getenv("ROOKERY_PARTY") != null) scope.launch { kotlinx.coroutines.delay(5000); testParty() } + } + } + Window( + onCloseRequest = ::exitApplication, state = state, title = "rookery", + onPreviewKeyEvent = { handleTimerKey(model, it) }, + ) { + RookeryApp(model) + } + } +} + +/** `rookery --import ...`: import csTimer .txt or Letter Pair Gym .zip files without opening a window. */ +private fun headlessImport(paths: List) { + val config = LocalConfig() + val root = Paths.get(config.syncFolder).also { it.createDirectories() } + val repo = Repo(Store(config.deviceId, FolderLogStorage(root, config.deviceId), FolderBlobStore(root), System::currentTimeMillis)) + val host = DesktopHost(config) + val importer = coffee.liz.rookery.importer.Importer(repo) + for (p in paths) { + println("» $p") + val summary = kotlinx.coroutines.runBlocking { + if (p.lowercase().endsWith(".zip") || p.endsWith("backup.json")) { + val (text, files) = host.readImport(p, "backup.json") + importer.letterPairGym(text, files) + } else importer.csTimer(host.readFile(p).decodeToString()) + } + summary.lines.forEach { println(" ✓ $it") } + summary.warnings.forEach { println(" ! $it") } + } +} -- cgit v1.3