feat: add CMD_BATTERY host-side handling for keyboard battery level

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
This commit is contained in:
2026-04-15 03:38:10 -04:00
parent d4a820f41c
commit ac1a061b11
4 changed files with 213 additions and 6 deletions
+129
View File
@@ -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<u8>;
// ---------------------------------------------------------------------------
// 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<u8>) {
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),
_ => {}
}
}
+30 -3
View File
@@ -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<Keyboard>) {
///
/// `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<Keyboard>,
battery_tx: Option<BatteryTx>,
) {
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<Keyboard>) {
}
}
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}",
+41 -3
View File
@@ -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::<u8>(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")?;
}
+13
View File
@@ -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 {