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 log::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::battery::BatteryTx;
|
||||||
use crate::keyboard::Keyboard;
|
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
|
/// Run the bridge. Blocks the calling thread until the `from_kbds` channel
|
||||||
/// is closed (i.e. all keyboard I/O threads have exited).
|
/// 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
|
/// `keyboards` is the list of all connected keyboards. When a layer-sync
|
||||||
/// packet arrives from device `src_id`, it is forwarded to every keyboard
|
/// packet arrives from device `src_id`, it is forwarded to every keyboard
|
||||||
/// whose `device_id` differs from `src_id`.
|
/// 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());
|
info!("Bridge started ({} keyboard(s))", keyboards.len());
|
||||||
|
|
||||||
for (src_id, pkt) in from_kbds {
|
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 => {
|
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.
|
// Log unexpected commands for diagnosability.
|
||||||
debug!(
|
debug!(
|
||||||
"Unexpected packet from device 0x{:02x}: cmd=0x{:02x}",
|
"Unexpected packet from device 0x{:02x}: cmd=0x{:02x}",
|
||||||
|
|||||||
+41
-3
@@ -47,6 +47,7 @@ use clap::Parser;
|
|||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
|
|
||||||
mod app_monitor;
|
mod app_monitor;
|
||||||
|
mod battery;
|
||||||
mod bridge;
|
mod bridge;
|
||||||
mod brightness;
|
mod brightness;
|
||||||
mod keyboard;
|
mod keyboard;
|
||||||
@@ -100,6 +101,13 @@ struct Args {
|
|||||||
/// toggled off once the feature is implemented.
|
/// toggled off once the feature is implemented.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_active_app: bool,
|
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 {
|
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 {
|
for kbd in &keyboards {
|
||||||
let q = Packet::layer_sync_query(DEV_HOST);
|
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
|
// 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();
|
let kbds = keyboards.clone();
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("bridge".into())
|
.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")?;
|
.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_VOLUME: u8 = 0x41; // Volume level (host → keyboard)
|
||||||
pub const CMD_BRIGHTNESS: u8 = 0x42; // Brightness (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_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
|
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
|
// ACTIVE_APP: max bytes for the null-terminated app name in payload
|
||||||
pub const APP_NAME_MAX: usize = 28;
|
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
|
// Packet type
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -147,6 +153,13 @@ impl Packet {
|
|||||||
p
|
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`
|
/// Build an ACTIVE_APP packet with `name` (truncated to `APP_NAME_MAX`
|
||||||
/// bytes, always null-terminated). Reserved — no firmware action yet.
|
/// bytes, always null-terminated). Reserved — no firmware action yet.
|
||||||
pub fn active_app(src: u8, name: &str) -> Self {
|
pub fn active_app(src: u8, name: &str) -> Self {
|
||||||
|
|||||||
Reference in New Issue
Block a user