diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 09:36:32 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 09:36:32 -0800 |
| commit | 60426ce45763d0a4a5572db6926e8e677cbc0dc0 (patch) | |
| tree | 0b8726b1d3f493cbf5adcecf10997e3c72e27762 /desktop/src/main | |
| parent | 6004b4cda755202491648e0d724b66d50eb0b96a (diff) | |
| download | rookery-60426ce45763d0a4a5572db6926e8e677cbc0dc0.tar.gz rookery-60426ce45763d0a4a5572db6926e8e677cbc0dc0.zip | |
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 <noreply@anthropic.com>
Diffstat (limited to 'desktop/src/main')
| -rw-r--r-- | desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt | 43 | ||||
| -rw-r--r-- | desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt | 22 |
2 files changed, 64 insertions, 1 deletions
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<String> = 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<String>) { override suspend fun fetchBytes(url: String) = ByteArray(0) 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 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<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 } - 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 } |
