commit d00c4aa371fadbf71eb936140d39d25abc737ac0 Author: rootiest Date: Fri Apr 10 14:42:09 2026 -0400 feat: initial qmk-host Raw HID bridge implementation Add a Rust daemon that connects to QMK keyboards over their Raw HID interface (VID 0x3434, usage page 0xFF60 / usage 0x0061) and provides: - Bi-directional layer sync: CMD_LAYER_SYNC (0x40) packets are bridged between all connected keyboards so their active layers stay in sync. On connect, a FLAG_QUERY packet requests the current state from each keyboard. A second keyboard (numpad) is stubbed and ready to enable. - Volume monitoring: polls `pactl get-sink-volume @DEFAULT_SINK@` every 2 s and pushes CMD_VOLUME (0x41) packets on change. Compatible with both PulseAudio and PipeWire on KDE6/Wayland. - Brightness monitoring: polls /sys/class/backlight on the same interval and pushes CMD_BRIGHTNESS (0x42) packets on change. - Active-app stub: CMD_ACTIVE_APP (0x43) is reserved with detailed notes on KDE6/Wayland implementation approaches (KWin D-Bus, foreign toplevel protocol) for a future commit. Each keyboard runs a single combined read/write IO thread using a 10 ms read timeout to interleave HID reads and queued writes without needing two file handles. Protocol constants in src/protocol.rs mirror hid_protocol.h in the QMK firmware exactly. Licensed under GPL-3.0-or-later. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4a58146 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "qmk-host" +version = "0.1.0" +edition = "2021" +description = "Host bridge for bi-directional Raw HID layer sync and host data delivery to QMK keyboards" +license = "GPL-3.0-or-later" + +[[bin]] +name = "qmk-host" +path = "src/main.rs" + +[dependencies] +hidapi = "2" # Cross-platform Raw HID access +anyhow = "1" # Ergonomic error handling +log = "0.4" # Logging facade +env_logger = "0.11" # Logger implementation (RUST_LOG env var) +ctrlc = "3" # Ctrl+C / SIGINT handler diff --git a/src/app_monitor.rs b/src/app_monitor.rs new file mode 100644 index 0000000..8a95cd1 --- /dev/null +++ b/src/app_monitor.rs @@ -0,0 +1,46 @@ +// Copyright 2026 rootiest +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Active application / window monitor — **reserved, not yet implemented**. +//! +//! When implemented this module will detect the active window on a KDE6 +//! Wayland desktop and send `CMD_ACTIVE_APP` (0x43) packets containing the +//! application name (null-terminated UTF-8, max 28 bytes) to all connected +//! keyboards. +//! +//! # Implementation notes for KDE6 / Wayland +//! +//! Candidate approaches (to evaluate during the brainstorming phase): +//! +//! 1. **KWin D-Bus interface** — `org.kde.KWin` exposes `activeWindow()` on +//! the scripting API. The window object has `resourceName` / `caption` +//! properties. Can subscribe to `windowActivated` signal. +//! → easiest; requires D-Bus session access. +//! +//! 2. **KWin Script** — inject a tiny `.kwinscript` that emits a custom +//! D-Bus signal or writes to a FIFO on `windowActivated`. +//! → portable across KWin versions; slightly invasive. +//! +//! 3. **`xdg-foreign` / `wlr-foreign-toplevel`** — Wayland protocols for +//! enumerating toplevels. KWin supports `ext-foreign-toplevel-list-v1`. +//! → protocol-level, no D-Bus, but requires a Wayland client loop. +//! +//! 4. **`qdbus6` / `busctl` polling** — parse output of +//! `busctl call org.kde.KWin /KWin org.kde.KWin activeWindow` +//! in a poll loop. Simple but coarse. +//! +//! The chosen approach will be implemented here once the brainstorming is +//! complete. The `Packet::active_app()` constructor in `protocol.rs` is +//! already in place. + +use log::info; + +use crate::keyboard::Keyboard; + +/// Monitor the active application and notify keyboards. +/// Currently a no-op stub — safe to call, does nothing. +pub fn monitor(_keyboards: Vec) { + info!("Active-application monitor: not yet implemented (stub)"); + // Future: block here in an event loop and send Packet::active_app() + // packets to keyboards whenever the active window changes. +} diff --git a/src/bridge.rs b/src/bridge.rs new file mode 100644 index 0000000..c742118 --- /dev/null +++ b/src/bridge.rs @@ -0,0 +1,70 @@ +// Copyright 2026 rootiest +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Layer-sync bridge. +//! +//! Receives packets arriving from any connected keyboard and decides what to +//! do with them: +//! +//! - `CMD_LAYER_SYNC` — forward the new layer state to every *other* keyboard +//! so they all stay in sync. +//! +//! The bridge is intentionally the only place that forwards keyboard→keyboard +//! packets; individual keyboards never talk to each other directly. This +//! central choke-point makes it easy to add filtering or logging later. + +use std::sync::mpsc; + +use log::{debug, info, warn}; + +use crate::keyboard::Keyboard; +use crate::protocol::{self, Packet, DEV_HOST}; + +/// Run the bridge. Blocks the calling thread until the `from_kbds` channel +/// is closed (i.e. all keyboard I/O threads have exited). +/// +/// `keyboards` is the list of all connected keyboards. When a layer-sync +/// packet arrives from device `src_id`, it is forwarded to every keyboard +/// whose `device_id` differs from `src_id`. +pub fn run(from_kbds: mpsc::Receiver<(u8, Packet)>, keyboards: Vec) { + info!("Bridge started ({} keyboard(s))", keyboards.len()); + + for (src_id, pkt) in from_kbds { + match pkt.cmd() { + protocol::CMD_LAYER_SYNC => { + let layer = pkt.payload()[protocol::LAYER_OFF_ACTIVE]; + let locked = pkt.payload()[protocol::LAYER_OFF_LOCKED]; + + debug!( + "Layer sync from device 0x{:02x}: layer={} locked_mask=0b{:08b}", + src_id, layer, locked + ); + + // Forward to all keyboards that did NOT originate this packet. + for kbd in &keyboards { + if kbd.device_id != src_id { + let fwd = Packet::layer_sync(DEV_HOST, layer, locked); + if kbd.sender.send(fwd).is_err() { + warn!( + "Failed to forward layer sync to device 0x{:02x} \ + (channel closed)", + kbd.device_id + ); + } + } + } + } + + other => { + // Keyboards should only send CMD_LAYER_SYNC back to us. + // Log unexpected commands for diagnosability. + debug!( + "Unexpected packet from device 0x{:02x}: cmd=0x{:02x}", + src_id, other + ); + } + } + } + + info!("Bridge exited (all keyboard channels closed)"); +} diff --git a/src/brightness.rs b/src/brightness.rs new file mode 100644 index 0000000..acd2114 --- /dev/null +++ b/src/brightness.rs @@ -0,0 +1,112 @@ +// Copyright 2026 rootiest +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Screen brightness monitor. +//! +//! Reads from `/sys/class/backlight//brightness` and +//! `/sys/class/backlight//max_brightness`, polling every 2 seconds +//! and sending `CMD_BRIGHTNESS` packets to all keyboards when the value +//! changes. +//! +//! On a KDE6 / Wayland desktop the backlight sysfs path is typically one of: +//! - `/sys/class/backlight/intel_backlight` +//! - `/sys/class/backlight/amdgpu_bl0` +//! - `/sys/class/backlight/acpi_video0` +//! +//! # Future improvement +//! Replace polling with an inotify watch on the `brightness` file so changes +//! are reflected immediately. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use log::{debug, error, info, warn}; + +use crate::keyboard::Keyboard; +use crate::protocol::{Packet, DEV_HOST}; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// Monitor screen brightness and send updates to `keyboards`. +/// Blocks the calling thread indefinitely. +pub fn monitor(keyboards: Vec) { + let Some(backlight) = find_backlight() else { + warn!( + "No backlight found in /sys/class/backlight — \ + brightness monitoring disabled" + ); + return; + }; + + info!("Monitoring brightness via {:?}", backlight); + + let mut last: Option = None; + + loop { + thread::sleep(POLL_INTERVAL); + + match read_brightness(&backlight) { + Some(bri) if last != Some(bri) => { + debug!("Brightness changed: {}%", bri); + last = Some(bri); + broadcast(Packet::brightness(DEV_HOST, bri), &keyboards); + } + None => { + warn!("Could not read brightness from {:?}", backlight); + } + _ => {} // unchanged + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Return the sysfs path to the first available backlight device. +fn find_backlight() -> Option { + let read_dir = fs::read_dir("/sys/class/backlight").ok()?; + for entry in read_dir.flatten() { + let path = entry.path(); + // Verify the required files exist before committing to this device. + if path.join("brightness").exists() && path.join("max_brightness").exists() { + return Some(path); + } + } + None +} + +/// Read the current brightness as a percentage (0-100) from a sysfs backlight +/// directory, or `None` if the files cannot be read / parsed. +fn read_brightness(backlight: &Path) -> Option { + let current: u64 = fs::read_to_string(backlight.join("brightness")) + .ok()? + .trim() + .parse() + .ok()?; + + let max: u64 = fs::read_to_string(backlight.join("max_brightness")) + .ok()? + .trim() + .parse() + .ok()?; + + if max == 0 { + return None; + } + + Some(((current * 100) / max) as u8) +} + +fn broadcast(pkt: Packet, keyboards: &[Keyboard]) { + for kbd in keyboards { + if kbd.sender.send(pkt.clone()).is_err() { + error!( + "brightness: failed to send to device 0x{:02x} (channel closed)", + kbd.device_id + ); + } + } +} diff --git a/src/keyboard.rs b/src/keyboard.rs new file mode 100644 index 0000000..724fe11 --- /dev/null +++ b/src/keyboard.rs @@ -0,0 +1,160 @@ +// Copyright 2026 rootiest +// SPDX-License-Identifier: GPL-3.0-or-later + +//! HID keyboard abstraction. +//! +//! Each `Keyboard` is a lightweight handle consisting of a device-id and a +//! channel sender. The actual HID I/O runs in a dedicated OS thread that +//! owns the `HidDevice`. Reads use a short timeout so the same thread can +//! also flush the outgoing write queue between reads — no second file handle +//! is needed. + +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Result}; +use hidapi::HidApi; +use log::{debug, error, info, warn}; + +use crate::protocol::{Packet, PACKET_SIZE, USAGE_ID, USAGE_PAGE}; + +// --------------------------------------------------------------------------- +// Public handle +// --------------------------------------------------------------------------- + +/// A connected keyboard with an outgoing packet channel. +/// +/// Clone to hand out additional sender handles (e.g. to monitor threads). +#[derive(Clone)] +pub struct Keyboard { + /// The device-id byte used in outgoing packets (e.g. `DEV_Q5MAX`). + pub device_id: u8, + /// Send a packet to this keyboard. Non-blocking if the channel has room. + pub sender: mpsc::SyncSender, +} + +// --------------------------------------------------------------------------- +// Connection +// --------------------------------------------------------------------------- + +/// Connect to a keyboard identified by `vendor_id` / `product_id`, matching +/// the raw-HID interface via Usage Page / Usage ID. +/// +/// Spawns one background thread that owns the `HidDevice` and handles both +/// reads and writes using a 10 ms read timeout to interleave them. +/// +/// Received custom packets are forwarded to `from_kbd` as `(device_id, Packet)`. +pub fn connect( + vendor_id: u16, + product_id: u16, + device_id: u8, + from_kbd: mpsc::SyncSender<(u8, Packet)>, +) -> Result { + // Create a fresh HID API context for enumeration. The HidDevice that we + // open from it has an independent lifetime and remains valid after the + // HidApi is dropped (libhidapi keeps the fd open). + let api = HidApi::new().context("Failed to initialise HID API")?; + + let device_info = api + .device_list() + .find(|d| { + d.vendor_id() == vendor_id + && d.product_id() == product_id + && d.usage_page() == USAGE_PAGE + && d.usage() == USAGE_ID + }) + .with_context(|| { + format!( + "No raw-HID interface found for {:04x}:{:04x} \ + (usage_page=0x{:04x} usage=0x{:04x}). \ + Is the keyboard plugged in and does udev allow access?", + vendor_id, product_id, USAGE_PAGE, USAGE_ID, + ) + })?; + + let device = api + .open_path(device_info.path()) + .context("Failed to open HID device")?; + + info!( + "Connected to {:04x}:{:04x} — {} (path: {:?})", + vendor_id, + product_id, + device_info.product_string().unwrap_or("unknown"), + device_info.path(), + ); + + // Outgoing packet channel (bounded to provide back-pressure). + let (tx, rx) = mpsc::sync_channel::(32); + + // Spawn the combined read/write thread. + thread::Builder::new() + .name(format!("kbd-io-{:02x}", device_id)) + .spawn(move || io_loop(device, device_id, from_kbd, rx)) + .context("Failed to spawn keyboard I/O thread")?; + + Ok(Keyboard { device_id, sender: tx }) +} + +// --------------------------------------------------------------------------- +// I/O thread +// --------------------------------------------------------------------------- + +/// Combined read/write loop for a single HID device. +/// +/// Reads use a 10 ms timeout so we can flush pending writes between reads +/// without blocking for long. On read errors the thread sleeps 1 s and +/// retries; on channel closure it exits cleanly. +fn io_loop( + device: hidapi::HidDevice, + device_id: u8, + to_broker: mpsc::SyncSender<(u8, Packet)>, + rx: mpsc::Receiver, +) { + // hidapi on Linux requires the report-ID byte (0x00) to be prepended. + let mut write_buf = [0u8; PACKET_SIZE + 1]; + write_buf[0] = 0x00; + + let mut read_buf = [0u8; PACKET_SIZE]; + + loop { + // --- Drain the outgoing write queue (non-blocking) ----------------- + while let Ok(pkt) = rx.try_recv() { + write_buf[1..].copy_from_slice(&pkt.0); + if let Err(e) = device.write(&write_buf) { + error!("[kbd {:02x}] write error: {}", device_id, e); + } else { + debug!("[kbd {:02x}] sent cmd=0x{:02x}", device_id, pkt.cmd()); + } + } + + // --- Blocking read with short timeout (10 ms) ---------------------- + match device.read_timeout(&mut read_buf, 10) { + Ok(0) => { + // Timeout — no data, loop around to check writes again. + } + Ok(_n) => { + let pkt = Packet(read_buf); + if pkt.is_custom() { + debug!( + "[kbd {:02x}] received cmd=0x{:02x} flags=0x{:02x}", + device_id, + pkt.cmd(), + pkt.flags() + ); + if to_broker.send((device_id, pkt)).is_err() { + // Broker has gone away — exit cleanly. + info!("[kbd {:02x}] broker channel closed, exiting", device_id); + return; + } + } + } + Err(e) => { + error!("[kbd {:02x}] read error: {}", device_id, e); + thread::sleep(Duration::from_secs(1)); + } + } + + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..76a8374 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,165 @@ +// Copyright 2026 rootiest +// SPDX-License-Identifier: GPL-3.0-or-later + +//! qmk-host — Raw HID bridge between QMK keyboards and the host. +//! +//! # What it does +//! +//! * Connects to each supported QMK keyboard over its raw-HID interface +//! (USB Usage Page 0xFF60, Usage 0x0061). +//! * Bridges `CMD_LAYER_SYNC` packets between keyboards so their active +//! layers stay in sync. +//! * Monitors system volume (`pactl`) and screen brightness (`/sys/class/backlight`) +//! and pushes `CMD_VOLUME` / `CMD_BRIGHTNESS` packets to all keyboards. +//! * Provides a stub for future `CMD_ACTIVE_APP` delivery. +//! +//! # Usage +//! +//! ``` +//! RUST_LOG=debug qmk-host +//! ``` +//! +//! # Permissions +//! +//! On Linux the raw-HID hidraw nodes require either root or a udev rule such +//! as: +//! +//! ```udev +//! SUBSYSTEM=="hidraw", ATTRS{idVendor}=="3434", ATTRS{idProduct}=="0850", \ +//! MODE="0660", GROUP="input" +//! ``` +//! +//! Place this in `/etc/udev/rules.d/99-qmk.rules` and run +//! `sudo udevadm control --reload-rules && sudo udevadm trigger`. + +use std::sync::mpsc; +use std::thread; + +use anyhow::{Context, Result}; +use log::{error, info, warn}; + +mod app_monitor; +mod bridge; +mod brightness; +mod keyboard; +mod protocol; +mod volume; + +use protocol::{DEV_HOST, Packet}; + +fn main() -> Result<()> { + // Initialise logging. Set RUST_LOG=debug for verbose output. + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + info!("qmk-host starting"); + + // ------------------------------------------------------------------ + // Shared inbound channel: keyboards → broker / bridge + // ------------------------------------------------------------------ + let (kbd_tx, kbd_rx) = mpsc::sync_channel::<(u8, Packet)>(64); + + // ------------------------------------------------------------------ + // Connect to keyboards + // ------------------------------------------------------------------ + let mut keyboards: Vec = Vec::new(); + + // Keychron Q5 Max + match keyboard::connect( + protocol::VENDOR_ID, + protocol::Q5MAX_PID, + protocol::DEV_Q5MAX, + kbd_tx.clone(), + ) { + Ok(kbd) => { + info!("Q5 Max connected (device id 0x{:02x})", kbd.device_id); + keyboards.push(kbd); + } + Err(e) => { + warn!("Q5 Max not found — layer sync unavailable: {:#}", e); + } + } + + // Numpad — uncomment and set NUMPAD_PID when the second keyboard firmware + // is ready. + // + // match keyboard::connect( + // protocol::VENDOR_ID, + // protocol::NUMPAD_PID, + // protocol::DEV_NUMPAD, + // kbd_tx.clone(), + // ) { + // Ok(kbd) => { + // info!("Numpad connected (device id 0x{:02x})", kbd.device_id); + // keyboards.push(kbd); + // } + // Err(e) => warn!("Numpad not found: {:#}", e), + // } + + if keyboards.is_empty() { + anyhow::bail!("No keyboards found — nothing to do, exiting"); + } + + // ------------------------------------------------------------------ + // Query initial layer state from each keyboard on connect + // ------------------------------------------------------------------ + for kbd in &keyboards { + let q = Packet::layer_sync_query(DEV_HOST); + if kbd.sender.send(q).is_err() { + error!("Failed to send initial layer query to device 0x{:02x}", kbd.device_id); + } + } + + // ------------------------------------------------------------------ + // Spawn worker threads + // ------------------------------------------------------------------ + + // Bridge: receives packets from any keyboard, forwards layer sync to peers. + { + let kbds = keyboards.clone(); + thread::Builder::new() + .name("bridge".into()) + .spawn(move || bridge::run(kbd_rx, kbds)) + .context("Failed to spawn bridge thread")?; + } + + // Volume monitor: polls pactl, pushes CMD_VOLUME to all keyboards. + { + let kbds = keyboards.clone(); + thread::Builder::new() + .name("volume-monitor".into()) + .spawn(move || volume::monitor(kbds)) + .context("Failed to spawn volume monitor thread")?; + } + + // Brightness monitor: polls /sys, pushes CMD_BRIGHTNESS to all keyboards. + { + let kbds = keyboards.clone(); + thread::Builder::new() + .name("brightness-monitor".into()) + .spawn(move || brightness::monitor(kbds)) + .context("Failed to spawn brightness monitor thread")?; + } + + // Active-app monitor: stub, does nothing until implemented. + { + let kbds = keyboards.clone(); + thread::Builder::new() + .name("app-monitor".into()) + .spawn(move || app_monitor::monitor(kbds)) + .context("Failed to spawn app-monitor thread")?; + } + + // ------------------------------------------------------------------ + // Wait for Ctrl+C / SIGINT + // ------------------------------------------------------------------ + let (shutdown_tx, shutdown_rx) = mpsc::sync_channel::<()>(1); + ctrlc::set_handler(move || { + info!("Received shutdown signal"); + let _ = shutdown_tx.send(()); + }) + .context("Failed to install Ctrl+C handler")?; + + shutdown_rx.recv().ok(); + info!("qmk-host stopped"); + Ok(()) +} diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..9b0e440 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,166 @@ +// Copyright 2026 rootiest +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Shared Raw HID packet protocol. +//! +//! All constants and layout MUST match `hid_protocol.h` in the QMK keymap. +//! +//! # Packet layout (32 bytes) +//! +//! ```text +//! Byte 0 : Command ID +//! Byte 1 : Source Device ID +//! Byte 2 : Flags +//! Bytes 3-31: Payload (29 bytes) +//! ``` + +// --------------------------------------------------------------------------- +// Physical HID descriptor constants (match QMK defaults) +// --------------------------------------------------------------------------- + +/// USB Vendor ID for Keychron keyboards. +pub const VENDOR_ID: u16 = 0x3434; + +/// USB Product ID for the Keychron Q5 Max ANSI Encoder. +pub const Q5MAX_PID: u16 = 0x0850; + +/// USB Product ID for the numpad (to be assigned when the second keyboard +/// firmware is implemented). +pub const NUMPAD_PID: u16 = 0x0000; + +/// HID Usage Page for QMK raw HID (vendor-defined). +pub const USAGE_PAGE: u16 = 0xFF60; + +/// HID Usage ID for QMK raw HID (vendor-defined). +pub const USAGE_ID: u16 = 0x0061; + +/// Fixed packet size used by QMK raw HID (RAW_EPSIZE). +pub const PACKET_SIZE: usize = 32; + +// --------------------------------------------------------------------------- +// Command IDs — must stay in 0x40-0x7E to avoid VIA's range (0x01-0x3F) +// and the unhandled sentinel (0xFF). +// --------------------------------------------------------------------------- + +pub const CMD_LAYER_SYNC: u8 = 0x40; // Layer state (keyboard ↔ host ↔ keyboard) +pub const CMD_VOLUME: u8 = 0x41; // Volume level (host → keyboard) +pub const CMD_BRIGHTNESS: u8 = 0x42; // Brightness (host → keyboard) +pub const CMD_ACTIVE_APP: u8 = 0x43; // Active app (host → keyboard, reserved) +pub const CMD_ACK: u8 = 0x7E; // Generic ACK + +// --------------------------------------------------------------------------- +// Source Device IDs +// --------------------------------------------------------------------------- + +pub const DEV_HOST: u8 = 0x00; // qmk-host Rust application +pub const DEV_Q5MAX: u8 = 0x01; // Keychron Q5 Max +pub const DEV_NUMPAD: u8 = 0x02; // Numpad (future) + +// --------------------------------------------------------------------------- +// Packet flags (byte 2) +// --------------------------------------------------------------------------- + +pub const FLAG_QUERY: u8 = 0x01; // Request current state without changing it +pub const FLAG_RESPONSE: u8 = 0x02; // This packet is a response to a query + +// --------------------------------------------------------------------------- +// Byte offsets +// --------------------------------------------------------------------------- + +pub const OFF_CMD: usize = 0; +pub const OFF_SRC: usize = 1; +pub const OFF_FLAGS: usize = 2; +pub const OFF_PAYLOAD: usize = 3; + +// LAYER_SYNC payload offsets (relative to OFF_PAYLOAD) +pub const LAYER_OFF_ACTIVE: usize = 0; // active layer index (0-5) +pub const LAYER_OFF_LOCKED: usize = 1; // locked-layers bitmask + +// ACTIVE_APP: max bytes for the null-terminated app name in payload +pub const APP_NAME_MAX: usize = 28; + +// --------------------------------------------------------------------------- +// Packet type +// --------------------------------------------------------------------------- + +/// A 32-byte Raw HID packet. +#[derive(Clone, Debug)] +pub struct Packet(pub [u8; PACKET_SIZE]); + +impl Packet { + /// Create an empty packet with the given command and source device. + pub fn new(cmd: u8, src: u8) -> Self { + let mut data = [0u8; PACKET_SIZE]; + data[OFF_CMD] = cmd; + data[OFF_SRC] = src; + Packet(data) + } + + // -- Field accessors ---------------------------------------------------- + + pub fn cmd(&self) -> u8 { self.0[OFF_CMD] } + pub fn src(&self) -> u8 { self.0[OFF_SRC] } + pub fn flags(&self) -> u8 { self.0[OFF_FLAGS] } + + /// Payload slice starting at byte 3. + pub fn payload(&self) -> &[u8] { &self.0[OFF_PAYLOAD..] } + + /// Returns true if this packet belongs to our custom command range. + pub fn is_custom(&self) -> bool { + (0x40..=0x7E).contains(&self.0[OFF_CMD]) + } + + // -- Constructors ------------------------------------------------------- + + /// Build a LAYER_SYNC packet advertising `layer` and `locked_mask`. + pub fn layer_sync(src: u8, layer: u8, locked_mask: u8) -> Self { + let mut p = Self::new(CMD_LAYER_SYNC, src); + p.0[OFF_PAYLOAD + LAYER_OFF_ACTIVE] = layer; + p.0[OFF_PAYLOAD + LAYER_OFF_LOCKED] = locked_mask; + p + } + + /// Build a LAYER_SYNC query (ask keyboard to report its current state). + pub fn layer_sync_query(src: u8) -> Self { + let mut p = Self::new(CMD_LAYER_SYNC, src); + p.0[OFF_FLAGS] = FLAG_QUERY; + p + } + + /// Build a VOLUME packet carrying `level` (0-100 %). + pub fn volume(src: u8, level: u8) -> Self { + let mut p = Self::new(CMD_VOLUME, src); + p.0[OFF_PAYLOAD] = level.min(100); + p + } + + /// Build a BRIGHTNESS packet carrying `level` (0-100 %). + pub fn brightness(src: u8, level: u8) -> Self { + let mut p = Self::new(CMD_BRIGHTNESS, src); + p.0[OFF_PAYLOAD] = level.min(100); + p + } + + /// Build an ACTIVE_APP packet with `name` (truncated to `APP_NAME_MAX` + /// bytes, always null-terminated). Reserved — no firmware action yet. + pub fn active_app(src: u8, name: &str) -> Self { + let mut p = Self::new(CMD_ACTIVE_APP, src); + let bytes = name.as_bytes(); + let len = bytes.len().min(APP_NAME_MAX - 1); // reserve space for NUL + p.0[OFF_PAYLOAD..OFF_PAYLOAD + len].copy_from_slice(&bytes[..len]); + // byte at OFF_PAYLOAD + len is already 0 (NUL terminator) + p + } +} + +impl Default for Packet { + fn default() -> Self { + Packet([0u8; PACKET_SIZE]) + } +} + +impl From<[u8; PACKET_SIZE]> for Packet { + fn from(data: [u8; PACKET_SIZE]) -> Self { + Packet(data) + } +} diff --git a/src/volume.rs b/src/volume.rs new file mode 100644 index 0000000..2e1b793 --- /dev/null +++ b/src/volume.rs @@ -0,0 +1,117 @@ +// Copyright 2026 rootiest +// SPDX-License-Identifier: GPL-3.0-or-later + +//! System volume monitor. +//! +//! Polls `pactl get-sink-volume @DEFAULT_SINK@` every 2 seconds and sends a +//! `CMD_VOLUME` packet to all keyboards when the value changes. +//! +//! This works on both PulseAudio and PipeWire (which exposes a PulseAudio +//! compatibility layer on KDE6 / Wayland systems). +//! +//! # Future improvement +//! Replace polling with a PipeWire or PulseAudio event subscription so volume +//! changes are reflected immediately rather than within the 2 s poll window. + +use std::process::Command; +use std::thread; +use std::time::Duration; + +use log::{debug, error, warn}; + +use crate::keyboard::Keyboard; +use crate::protocol::{Packet, DEV_HOST}; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// Monitor system volume and send updates to `keyboards`. +/// Blocks the calling thread indefinitely (intended to run in a dedicated thread). +pub fn monitor(keyboards: Vec) { + let mut last: Option = None; + + loop { + thread::sleep(POLL_INTERVAL); + + match query_volume() { + Some(vol) if last != Some(vol) => { + debug!("Volume changed: {}%", vol); + last = Some(vol); + broadcast(Packet::volume(DEV_HOST, vol), &keyboards); + } + None => { + warn!("Could not read system volume"); + } + _ => {} // unchanged + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Query the current default-sink volume via `pactl`. +/// Returns the percentage (0-100) of the first channel, or `None` on failure. +fn query_volume() -> Option { + let out = Command::new("pactl") + .args(["get-sink-volume", "@DEFAULT_SINK@"]) + .output() + .ok()?; + + if !out.status.success() { + return None; + } + + parse_volume(&String::from_utf8_lossy(&out.stdout)) +} + +/// Parse the first percentage value from pactl output such as: +/// `"Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% ..."` +fn parse_volume(s: &str) -> Option { + for part in s.split('/') { + let trimmed = part.trim(); + if let Some(stripped) = trimmed.strip_suffix('%') { + if let Ok(n) = stripped.trim().parse::() { + return Some(n); + } + } + } + None +} + +fn broadcast(pkt: Packet, keyboards: &[Keyboard]) { + for kbd in keyboards { + if kbd.sender.send(pkt.clone()).is_err() { + error!( + "volume: failed to send to device 0x{:02x} (channel closed)", + kbd.device_id + ); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::parse_volume; + + #[test] + fn parses_stereo_output() { + let s = "Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB"; + assert_eq!(parse_volume(s), Some(100)); + } + + #[test] + fn parses_partial_volume() { + let s = "Volume: front-left: 45875 / 70% / -9.03 dB, front-right: 45875 / 70% / -9.03 dB"; + assert_eq!(parse_volume(s), Some(70)); + } + + #[test] + fn returns_none_for_garbage() { + assert_eq!(parse_volume("no percentage here"), None); + } +}