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.
This commit is contained in:
2026-04-10 14:42:09 -04:00
commit d00c4aa371
8 changed files with 853 additions and 0 deletions
+17
View File
@@ -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
+46
View File
@@ -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<Keyboard>) {
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.
}
+70
View File
@@ -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<Keyboard>) {
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)");
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2026 rootiest
// SPDX-License-Identifier: GPL-3.0-or-later
//! Screen brightness monitor.
//!
//! Reads from `/sys/class/backlight/<device>/brightness` and
//! `/sys/class/backlight/<device>/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<Keyboard>) {
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<u8> = 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<PathBuf> {
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<u8> {
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
);
}
}
}
+160
View File
@@ -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<Packet>,
}
// ---------------------------------------------------------------------------
// 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<Keyboard> {
// 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::<Packet>(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<Packet>,
) {
// 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));
}
}
}
}
+165
View File
@@ -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<keyboard::Keyboard> = 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(())
}
+166
View File
@@ -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)
}
}
+117
View File
@@ -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<Keyboard>) {
let mut last: Option<u8> = 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<u8> {
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<u8> {
for part in s.split('/') {
let trimmed = part.trim();
if let Some(stripped) = trimmed.strip_suffix('%') {
if let Ok(n) = stripped.trim().parse::<u8>() {
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);
}
}