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:
+17
@@ -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
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user