From ac1a061b115cffd50012c9b3d47c9a8749a2f515 Mon Sep 17 00:00:00 2001 From: rootiest Date: Wed, 15 Apr 2026 03:38:10 -0400 Subject: [PATCH] feat: add CMD_BATTERY host-side handling for keyboard battery level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the host half of the HID_CMD_BATTERY (0x44) protocol added to the Q5 Max firmware. The keyboard pushes its battery percentage every 5 minutes when in wireless transport mode; the host can also query immediately at startup. Changes across four files: protocol.rs - Add CMD_BATTERY = 0x44 command constant - Add BATT_OFF_LEVEL payload offset and BATT_UNAVAILABLE (0xFF) sentinel - Add Packet::battery_query() constructor (FLAG_QUERY → keyboard responds) battery.rs (new) - Monitor thread receives battery levels via mpsc channel from the bridge - Writes current level to $XDG_RUNTIME_DIR/qmk-battery (fallback /tmp) as a plain ASCII integer; readable with cat for scripts/status bars - Fires notify-send at two downward threshold crossings: ≤ 20% → normal-urgency "Battery low" notification ≤ 10% → critical-urgency "Battery critical" notification - Each threshold resets when battery recovers above it (charging detected) - BATT_UNAVAILABLE (0xFF) responses are silently skipped bridge.rs - Handle CMD_BATTERY packets received from keyboards - Forward the level byte to the battery monitor channel (when enabled) main.rs - Add --no-battery-notify flag (discard battery packets, skip file/notify) - Create battery mpsc channel; spawn battery::monitor thread - Send Packet::battery_query() to each keyboard on startup for an immediate reading rather than waiting for the first 5-minute push --- src/battery.rs | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ src/bridge.rs | 33 +++++++++++-- src/main.rs | 44 +++++++++++++++-- src/protocol.rs | 13 +++++ 4 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 src/battery.rs diff --git a/src/battery.rs b/src/battery.rs new file mode 100644 index 0000000..1d3acb0 --- /dev/null +++ b/src/battery.rs @@ -0,0 +1,129 @@ +// Copyright 2026 rootiest +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Keyboard battery level monitor. +//! +//! Receives battery percentage updates forwarded by the bridge from +//! `CMD_BATTERY` packets, writes the current level to a state file, and fires +//! desktop notifications when the level crosses configured thresholds. +//! +//! # State file +//! +//! Written to `$XDG_RUNTIME_DIR/qmk-battery` (falls back to `/tmp/qmk-battery`). +//! Contains a single ASCII integer (0-100) followed by a newline. +//! +//! ```sh +//! cat "$XDG_RUNTIME_DIR/qmk-battery" +//! ``` +//! +//! # Notifications +//! +//! Fired via `notify-send` (requires `libnotify` or equivalent on PATH). +//! +//! Thresholds: +//! - **Low** (≤ 20%): normal-urgency notification, once per downward crossing. +//! - **Critical** (≤ 10%): critical-urgency notification, once per downward crossing. +//! +//! Each threshold resets when the battery rises back above it (i.e. charging). + +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::sync::mpsc; + +use log::{debug, info, warn}; + +use crate::protocol::BATT_UNAVAILABLE; + +// --------------------------------------------------------------------------- +// Thresholds +// --------------------------------------------------------------------------- + +const THRESHOLD_CRITICAL: u8 = 10; +const THRESHOLD_LOW: u8 = 20; + +// --------------------------------------------------------------------------- +// Public handle type +// --------------------------------------------------------------------------- + +/// Sender half for submitting battery levels to the monitor thread. +pub type BatteryTx = mpsc::SyncSender; + +// --------------------------------------------------------------------------- +// Monitor loop +// --------------------------------------------------------------------------- + +/// Run the battery monitor. Blocks the calling thread until the sender side +/// of `rx` is dropped (i.e. the bridge thread exits). +pub fn monitor(rx: mpsc::Receiver) { + let path = state_file_path(); + info!("Battery monitor started (state file: {})", path.display()); + + let mut notified_low = false; + let mut notified_critical = false; + + for level in rx { + if level == BATT_UNAVAILABLE { + debug!("Battery level unavailable (keyboard in USB/wired mode)"); + continue; + } + + info!("Battery level: {}%", level); + write_state_file(&path, level); + + // Reset notification flags when the battery recovers above a threshold + // so we fire again if it dips back down later (e.g. after charging). + if level > THRESHOLD_LOW { + notified_low = false; + notified_critical = false; + } else if level > THRESHOLD_CRITICAL { + notified_critical = false; + } + + // Fire on downward threshold crossings only. + if level <= THRESHOLD_CRITICAL && !notified_critical { + notified_critical = true; + notify("critical", "battery-caution", + &format!("Battery critical: {}% — plug in soon!", level)); + } else if level <= THRESHOLD_LOW && !notified_low { + notified_low = true; + notify("normal", "battery-low", + &format!("Battery low: {}%", level)); + } + } + + info!("Battery monitor exited"); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn state_file_path() -> PathBuf { + let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + PathBuf::from(dir).join("qmk-battery") +} + +fn write_state_file(path: &PathBuf, level: u8) { + if let Err(e) = fs::write(path, format!("{}\n", level)) { + warn!("Failed to write battery state file {}: {}", path.display(), e); + } +} + +/// Fire a desktop notification via `notify-send`. +fn notify(urgency: &str, icon: &str, body: &str) { + match Command::new("notify-send") + .args([ + "--urgency", urgency, + "--icon", icon, + "--app-name", "QMK Host", + "Keyboard Battery", + body, + ]) + .status() + { + Ok(s) if !s.success() => warn!("notify-send exited with {}", s), + Err(e) => warn!("Failed to run notify-send: {}", e), + _ => {} + } +} diff --git a/src/bridge.rs b/src/bridge.rs index c742118..ee410dd 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -17,8 +17,9 @@ use std::sync::mpsc; use log::{debug, info, warn}; +use crate::battery::BatteryTx; use crate::keyboard::Keyboard; -use crate::protocol::{self, Packet, DEV_HOST}; +use crate::protocol::{self, Packet, BATT_OFF_LEVEL, 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). @@ -26,7 +27,15 @@ use crate::protocol::{self, Packet, DEV_HOST}; /// `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) { +/// +/// `battery_tx` is optional: when `Some`, `CMD_BATTERY` packets are forwarded +/// to the battery monitor thread. When `None` (e.g. `--no-battery-notify`), +/// battery packets are silently discarded after logging. +pub fn run( + from_kbds: mpsc::Receiver<(u8, Packet)>, + keyboards: Vec, + battery_tx: Option, +) { info!("Bridge started ({} keyboard(s))", keyboards.len()); for (src_id, pkt) in from_kbds { @@ -55,8 +64,26 @@ pub fn run(from_kbds: mpsc::Receiver<(u8, Packet)>, keyboards: Vec) { } } + protocol::CMD_BATTERY => { + let level = pkt.payload()[BATT_OFF_LEVEL]; + debug!( + "Battery report from device 0x{:02x}: {}%", + src_id, + if level == protocol::BATT_UNAVAILABLE { + "unavailable".to_string() + } else { + format!("{}", level) + } + ); + if let Some(ref tx) = battery_tx { + if tx.send(level).is_err() { + warn!("Battery monitor channel closed — dropping battery packet"); + } + } + } + other => { - // Keyboards should only send CMD_LAYER_SYNC back to us. + // Keyboards should only send CMD_LAYER_SYNC / CMD_BATTERY back to us. // Log unexpected commands for diagnosability. debug!( "Unexpected packet from device 0x{:02x}: cmd=0x{:02x}", diff --git a/src/main.rs b/src/main.rs index 706f3df..688c42a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,7 @@ use clap::Parser; use log::{error, info, warn}; mod app_monitor; +mod battery; mod bridge; mod brightness; mod keyboard; @@ -100,6 +101,13 @@ struct Args { /// toggled off once the feature is implemented. #[arg(long)] no_active_app: bool, + + /// Disable battery level monitoring and notifications. + /// + /// When set, no CMD_BATTERY query is sent on startup, incoming battery + /// packets are discarded, and the state file is never written. + #[arg(long)] + no_battery_notify: bool, } impl Args { @@ -180,7 +188,7 @@ fn main() -> Result<()> { } // ------------------------------------------------------------------ - // Query initial layer state from each keyboard on connect + // Query initial state from each keyboard on connect // ------------------------------------------------------------------ for kbd in &keyboards { let q = Packet::layer_sync_query(DEV_HOST); @@ -189,16 +197,46 @@ fn main() -> Result<()> { } } + // ------------------------------------------------------------------ + // Battery monitor channel (keyboard → bridge → monitor thread) + // ------------------------------------------------------------------ + let battery_tx = if args.no_battery_notify { + info!("Battery monitoring disabled (--no-battery-notify)"); + None + } else { + let (bat_tx, bat_rx) = mpsc::sync_channel::(16); + + // Send an initial battery query to each keyboard so we get a reading + // immediately rather than waiting for the first periodic push. + for kbd in &keyboards { + let q = Packet::battery_query(DEV_HOST); + if kbd.sender.send(q).is_err() { + error!( + "Failed to send battery query to device 0x{:02x}", + kbd.device_id + ); + } + } + + thread::Builder::new() + .name("battery-monitor".into()) + .spawn(move || battery::monitor(bat_rx)) + .context("Failed to spawn battery monitor thread")?; + + Some(bat_tx) + }; + // ------------------------------------------------------------------ // Spawn worker threads // ------------------------------------------------------------------ - // Bridge: receives packets from any keyboard, forwards layer sync to peers. + // Bridge: receives packets from any keyboard, forwards layer sync to peers + // and battery levels to the battery monitor. { let kbds = keyboards.clone(); thread::Builder::new() .name("bridge".into()) - .spawn(move || bridge::run(kbd_rx, kbds)) + .spawn(move || bridge::run(kbd_rx, kbds, battery_tx)) .context("Failed to spawn bridge thread")?; } diff --git a/src/protocol.rs b/src/protocol.rs index cef9210..0dfebcb 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -52,6 +52,7 @@ pub const CMD_LAYER_SYNC: u8 = 0x40; // Layer state (keyboard ↔ host ↔ keyb 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_BATTERY: u8 = 0x44; // Battery level (keyboard → host; wireless only) pub const CMD_ACK: u8 = 0x7E; // Generic ACK // --------------------------------------------------------------------------- @@ -85,6 +86,11 @@ 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; +// BATTERY payload offsets (relative to OFF_PAYLOAD) +pub const BATT_OFF_LEVEL: usize = 0; // battery percentage 0-100 +/// Sentinel value: keyboard is in USB/wired mode — level not meaningful. +pub const BATT_UNAVAILABLE: u8 = 0xFF; + // --------------------------------------------------------------------------- // Packet type // --------------------------------------------------------------------------- @@ -147,6 +153,13 @@ impl Packet { p } + /// Build a BATTERY query (ask keyboard to report its current level). + pub fn battery_query(src: u8) -> Self { + let mut p = Self::new(CMD_BATTERY, src); + p.0[OFF_FLAGS] = FLAG_QUERY; + 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 {