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