From 60426ce45763d0a4a5572db6926e8e677cbc0dc0 Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Fri, 13 Nov 2026 09:36:32 -0800 Subject: Plain UI copy; smart cube pairing; stackmat input - Remove marquee, snow, pointer-following, hostname line and penguin speech; the penguin keeps to its corner. UI text is plain labels. - Smart cube: scan / pair / connect / forget in settings, with live battery, move count and an unfolded view of the cube's state, and "mark solved" to resync. Paired cubes go in the synced device list. CubeBackend abstracts the platform; Linux (BlueZ) only for now. - Stackmat: audio decoder for the timer's 1200-baud data port (edge based, so AC coupling and inverted inputs work; 5- and 6-digit packets, checksum verified) and a session that records each solve once. Settings pick the input and show signal state; when enabled the mat drives the timer display and lamps instead of the keyboard. Tested against synthetic audio only. Co-Authored-By: Claude Fable 5.2 --- .../main/kotlin/coffee/liz/rookery/desktop/Main.kt | 43 ++++++++++++++++++++++ .../kotlin/coffee/liz/rookery/desktop/Shots.kt | 22 ++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) (limited to 'desktop/src') 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 c2f65ca..085ad9a 100644 --- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt @@ -9,7 +9,13 @@ 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.bt.BlueZTransport +import coffee.liz.rookery.bt.CubeBackend +import coffee.liz.rookery.bt.GanCubeConnector +import coffee.liz.rookery.bt.JvmAes128 import coffee.liz.rookery.images.BraveImages +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filter import coffee.liz.rookery.images.ImageHit import coffee.liz.rookery.model.Event import coffee.liz.rookery.scramble.TnoodleScrambler @@ -50,6 +56,9 @@ class LocalConfig { runCatching { java.nio.file.Files.setPosixFilePermissions(file, java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")) } } + fun get(key: String): String? = props.getProperty(key) + fun set(key: String, value: String?) { if (value == null) props.remove(key) else props.setProperty(key, value); save() } + /** Brave Search API key: env ROOKERY_BRAVE_KEY, else `braveApiKey` in this (never synced) file. */ val braveApiKey: String? get() = System.getenv("ROOKERY_BRAVE_KEY")?.takeIf { it.isNotBlank() } ?: props.getProperty("braveApiKey")?.takeIf { it.isNotBlank() } @@ -142,6 +151,40 @@ 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() } + 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) + } + + override val audioSampleRate: Int get() = 44_100 + private val audioFormat = javax.sound.sampled.AudioFormat(44_100f, 16, 1, true, false) + private val lineInfo = javax.sound.sampled.DataLine.Info(javax.sound.sampled.TargetDataLine::class.java, audioFormat) + + override fun audioInputs(): List = javax.sound.sampled.AudioSystem.getMixerInfo() + .filter { runCatching { javax.sound.sampled.AudioSystem.getMixer(it).isLineSupported(lineInfo) }.getOrDefault(false) }.map { it.name }.distinct() + + override fun openAudio(input: String?, onSamples: (FloatArray, Int) -> Unit): AutoCloseable { + val mixer = javax.sound.sampled.AudioSystem.getMixerInfo().firstOrNull { it.name == input }?.let { javax.sound.sampled.AudioSystem.getMixer(it) } + val line = try { + ((mixer?.getLine(lineInfo) ?: javax.sound.sampled.AudioSystem.getLine(lineInfo)) as javax.sound.sampled.TargetDataLine).apply { open(audioFormat, 8192); start() } + } catch (e: Exception) { error("couldn't open ${input ?: "the default input"} (${e.message})") } + val thread = Thread { + val bytes = ByteArray(2048); val samples = FloatArray(1024) + while (line.isOpen) { + val n = line.read(bytes, 0, bytes.size) + if (n <= 0) break + for (i in 0 until n / 2) samples[i] = (((bytes[2 * i + 1].toInt() shl 8) or (bytes[2 * i].toInt() and 0xFF)).toShort()) / 32768f + onSamples(samples, n / 2) + } + }.apply { isDaemon = true; name = "stackmat-audio"; start() } + return AutoCloseable { line.stop(); line.close(); thread.interrupt() } + } + + override fun localPref(key: String): String? = config.get("local.$key") + override fun setLocalPref(key: String, value: String?) = config.set("local.$key", value) + var onFullscreen: (Boolean) -> Unit = {} override fun setFullscreen(on: Boolean) = onFullscreen(on) 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 d7621eb..6a00c24 100644 --- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt @@ -57,6 +57,24 @@ fun shots(args: List) { override suspend fun fetchBytes(url: String) = ByteArray(0) 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 suspend fun requestFacelets() {}; override suspend fun requestBattery() {}; override suspend fun requestHardware() {} + override suspend fun resetToSolved() {}; override suspend fun disconnect() {} + } + } + override fun audioInputs() = listOf("USB Audio Device") + override fun openAudio(input: String?, onSamples: (FloatArray, Int) -> Unit) = AutoCloseable { } + override val audioSampleRate = 44_100 + override fun localPref(key: String): String? = null + override fun setLocalPref(key: String, value: String?) {} } val model = AppModel(repo, TnoodleScrambler(), host, CoroutineScope(SupervisorJob() + Dispatchers.Default)) model.selectSession("s1") @@ -78,7 +96,9 @@ 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 } - shoot("settings") { model.tab = Tab.SETTINGS } + 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) } + model.stackmat.change(false) shoot("import") { model.tab = Tab.IMPORT } model.setTheme(ThemeMode.LIGHT) shoot("timer-paper") { model.tab = Tab.TIMER } -- cgit v1.3