diff --git a/Cargo.lock b/Cargo.lock index 711d193..e39e436 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2920,7 +2920,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openmouse-bridge" -version = "1.0.0-beta.18" +version = "1.0.0-beta.19" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 0a1e674..4028620 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openmouse-bridge" -version = "1.0.0-beta.18" +version = "1.0.0-beta.19" edition = "2024" description = "Local native HID and desktop companion for the OpenMouse control panel" license = "MIT" diff --git a/README.md b/README.md index be85016..0c203ad 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,33 @@ portable so additional desktop adapters do not require changes to the web app. The initial service provides: -- a compact tray panel for status, startup, battery, and update controls; +- a compact tray panel for status, startup, battery, and update controls, + with a device summary row set showing the mouse's current DPI, polling + rate, and switch mode as read from its onboard memory; +- a live tray icon that shows the connected mouse's battery, drawn square at + the taskbar's real pixel size with either the native Segoe Fluent Icons + battery glyph (Segoe MDL2 Assets on Windows 10) or the percentage number in + bold text — a control-panel choice, since the Windows 11 taskbar only + renders square tray icons faithfully — tinted by charge state: green while + charging (the variant with the built-in bolt) or above half, amber through + the mid band, alert red at or below the low-battery threshold, gray when + the reading is stale, and a level glyph for every 10% — with a tooltip that + spells everything out — `Device — 87% · Charging · full in 45 m` — + including the estimated time remaining from the recent trend; - process-based detection for configured game executables; +- startup discovery: Bridge scans the HID bus for mice whose brands it has + drivers for (the same vendor table the native-hid helper drives — Pulsar, + Logitech, Razer, Delux, VGN, and more), so the tray shows the battery from + the first second — no web panel connection and no saved profile required; +- hybrid device mode (toggle in the panel): while the OpenMouse web app is + disconnected, Bridge drives the mouse itself in onboard memory mode — the + active profile's stored DPI, polling rate, and switch mode are pushed + natively (switch mode wherever a driver supports it, e.g. Logitech + LightForce), and the battery is read directly (including a native Pulsar + battery reader) — and the moment the web app connects, it takes the mouse + over live, with Bridge restoring the stored settings and reading the + battery again on disconnect; without a saved profile Bridge leaves the + mouse's own onboard memory untouched and just monitors it; - discovery of visible applications and the foreground application (on Linux, foreground detection needs X11; on Wayland every entry reports `foreground: false`); - persistent application profiles tied to a specific mouse; - low-battery notifications with a configurable threshold and cooldown, from diff --git a/native-hid/src/apply.mjs b/native-hid/src/apply.mjs index 52c9d39..4e3b592 100644 --- a/native-hid/src/apply.mjs +++ b/native-hid/src/apply.mjs @@ -6,9 +6,14 @@ // Usage: node apply.mjs < request.json // stdin: {"brand": "Razer", "dpi": 800, "pollingRateHz": 1000} // (dpi / pollingRateHz are each optional — omit to leave that setting alone) +// stdin: {"brand": "Razer", "dpi": 800, "settings": {"lightforceSwitchMode": "Optical"}} +// (settings carries the panel's stored MouseStatus fields; ones the driver +// supports are applied, the rest are reported and skipped) // stdin: {"brand": "Razer", "action": "status"} -// reads the mouse's status instead and prints its battery fields as JSON on -// stdout: {"batteryPercent": 41, "batteryState": "Discharging"} +// reads the mouse's status instead and prints it as JSON on stdout: +// {"batteryPercent": 41, "batteryState": "Discharging", +// "deviceName": "G Pro X Superlight 2", "dpi": 800, "pollingRateHz": 1000, +// "switchMode": "Optical"} (settings fields null when unreadable) // // Exit codes (Bridge's src/drivers/native_hid.rs depends on this contract): // 0 — a device was found and the requested settings were applied (or, in @@ -138,6 +143,16 @@ async function main() { JSON.stringify({ batteryPercent: status?.batteryPercent ?? null, batteryState: status?.batteryState ?? null, + // The driver's own model name (MouseStatus.name) — better than the + // receiver-level name Bridge's discovery gets from the HID + // descriptor (a LIGHTSPEED receiver reports "USB Receiver"). + deviceName: status?.name ?? null, + // The settings currently in the device's onboard memory, for the + // tray panel's device summary. MouseStatus reports them whenever + // the driver can read them. + dpi: status?.dpi ?? null, + pollingRateHz: status?.pollingRateHz ?? null, + switchMode: status?.lightforceSwitchMode ?? null, }), ); await client.close().catch(() => undefined); @@ -205,6 +220,43 @@ async function main() { process.exit(EXIT_FAILED); } + // ── Panel snapshot fields ──────────────────────────────────────────── + // Bridge stores the panel's opaque MouseStatus fields alongside the + // numeric settings and hands them back here for onboard-memory mode. + // Only fields with a feature-detected driver method are applied; the + // rest are reported and skipped. + if (input.settings && typeof input.settings === "object" && !Array.isArray(input.settings)) { + const settings = input.settings; + const skipped = []; + if ( + typeof settings.lightforceSwitchMode === "string" && + typeof client.setLightforceSwitchMode === "function" + ) { + try { + await withTimeout( + client.setLightforceSwitchMode(settings.lightforceSwitchMode), + PROBE_TIMEOUT_MS, + "setLightforceSwitchMode", + ); + } catch (error) { + console.error(`[native-hid] could not apply lightforceSwitchMode: ${error.message}`); + await client.close().catch(() => undefined); + process.exit(EXIT_FAILED); + } + } else if (typeof settings.lightforceSwitchMode === "string") { + skipped.push("lightforceSwitchMode"); + } + const known = ["lightforceSwitchMode"]; + for (const field of Object.keys(settings)) { + if (!known.includes(field) && settings[field] !== null && settings[field] !== undefined) { + skipped.push(field); + } + } + if (skipped.length > 0) { + console.error(`[native-hid] no native support for: ${skipped.join(", ")} — skipped.`); + } + } + await client.close().catch(() => undefined); process.exit(EXIT_APPLIED); } diff --git a/src/config.rs b/src/config.rs index e16fd5e..a46b273 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,17 @@ pub struct BridgeConfig { pub notification_sound: bool, #[serde(default = "default_notification_volume")] pub notification_volume: u8, + /// What the tray battery icon shows: `"glyph"` (the native battery icon) + /// or `"percentage"` (just the number). Other values read as `"glyph"`. + #[serde(default = "default_tray_battery_style")] + pub tray_battery_style: String, + /// Hybrid device mode: while the OpenMouse web panel is disconnected, + /// Bridge drives the mouse itself — the active profile's stored settings + /// (DPI, polling rate, switch mode) go to the onboard memory and the + /// battery is read natively — and hands the mouse over to the panel the + /// moment it connects, applying the stored settings again on disconnect. + #[serde(default = "enabled_by_default")] + pub hybrid_mode: bool, #[serde(default)] pub games: Vec, /// Games the user added by hand. Kept apart from `games` because the @@ -184,6 +195,8 @@ impl Default for BridgeConfig { automatic_updates: false, notification_sound: true, notification_volume: DEFAULT_NOTIFICATION_VOLUME, + tray_battery_style: default_tray_battery_style(), + hybrid_mode: true, games: Vec::new(), custom_games: Vec::new(), local_applications: Vec::new(), @@ -201,6 +214,13 @@ impl BridgeConfig { self.battery_threshold_percent = self.battery_threshold_percent.min(100); self.notification_volume = self.notification_volume.min(100); self.alert_cooldown_minutes = self.alert_cooldown_minutes.max(1); + // Only the two known tray styles are honored; anything else (a + // hand-edited config, an older panel) reads as the default glyph. + self.tray_battery_style = match self.tray_battery_style.trim().to_ascii_lowercase().as_str() + { + "percentage" => "percentage".to_owned(), + _ => "glyph".to_owned(), + }; normalize_games(&mut self.games, &CATALOG_LIMITS); normalize_games(&mut self.custom_games, &CUSTOM_GAME_LIMITS); for profile in &mut self.profiles { @@ -423,6 +443,10 @@ const fn default_notification_volume() -> u8 { DEFAULT_NOTIFICATION_VOLUME } +fn default_tray_battery_style() -> String { + "glyph".to_owned() +} + fn default_origins() -> Vec { vec![ "https://control.openmouse.app".to_owned(), @@ -581,6 +605,8 @@ mod tests { notification_sound: true, notification_volume: 180, custom_games: Vec::new(), + tray_battery_style: " percentage ".to_owned(), + hybrid_mode: true, profiles: Vec::new(), default_profile: None, allowed_origins: Vec::new(), @@ -592,6 +618,7 @@ mod tests { assert_eq!(config.battery_threshold_percent, 100); assert_eq!(config.alert_cooldown_minutes, 1); assert_eq!(config.notification_volume, 100); + assert_eq!(config.tray_battery_style, "percentage"); let valorant = config .games .iter() @@ -630,6 +657,15 @@ mod tests { assert_eq!(config.custom_games[0].executables, ["mygame.exe"]); } + #[test] + fn hybrid_mode_defaults_to_on_and_normalizes_to_a_bool() { + let config: BridgeConfig = serde_json::from_str("{}").expect("empty config parses"); + assert!(config.hybrid_mode); + let config: BridgeConfig = + serde_json::from_str(r#"{"hybridMode": false}"#).expect("off config parses"); + assert!(!config.hybrid_mode); + } + #[test] fn profile_settings_keep_the_snapshot_and_accept_profiles_without_one() { let json = diff --git a/src/desktop.rs b/src/desktop.rs index 2e8f4d1..a334e0a 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -15,11 +15,12 @@ use std::{ use crate::overlay::Overlay; use anyhow::{Context, Result, anyhow}; use eframe::egui::{ - self, Align, Button, Frame, Layout, Pos2, RichText, Sense, Stroke, Vec2, ViewportCommand, + self, Align, Button, Color32, Frame, Layout, Pos2, RichText, Sense, Stroke, Vec2, + ViewportCommand, }; use openmouse_bridge::{ BRIDGE_PORT, api, config, platform, - service::{BridgeService, BridgeSnapshot, Notice}, + service::{BridgeService, BridgeSnapshot, DeviceBattery, Notice}, updater::{self, UpdateInfo}, }; #[cfg(target_os = "windows")] @@ -307,6 +308,8 @@ enum BridgeCommand { SetNotificationSound(bool), SetNotificationVolume(u8), SetAutomaticUpdates(bool), + SetHybridMode(bool), + SetTrayBatteryStyle(platform::TrayBatteryStyle), CheckForUpdates, InstallUpdate(UpdateInfo), AcknowledgeTrayHint, @@ -347,15 +350,47 @@ fn update_is_notice(state: &UpdateState) -> bool { ) } -fn panel_height(view: View, details: bool, saved_values: bool, notice: bool) -> f32 { +/// Height added to the Home view while a device-summary row is showing +/// (the mouse's live onboard-memory settings under the profile panel). +const DEVICE_SUMMARY_HEIGHT: f32 = 22.0; + +fn panel_height( + view: View, + details: bool, + saved_values: bool, + notice: bool, + device_summary: bool, +) -> f32 { if view == View::Settings { return 420.0; } if details { return 340.0; } - (WINDOW_HEIGHT + if saved_values { 18.0 } else { 0.0 } + if notice { 40.0 } else { 0.0 }) - .min(340.0) + let base = + (WINDOW_HEIGHT + if saved_values { 18.0 } else { 0.0 } + if notice { 40.0 } else { 0.0 }) + .min(340.0); + base + if device_summary { + DEVICE_SUMMARY_HEIGHT + } else { + 0.0 + } +} + +/// The battery entry whose onboard-memory settings the Home view renders +/// under the profile panel — the freshest non-stale one carrying any. Both +/// the renderer and the window sizing go through this so they stay in step. +fn device_summary_battery(snapshot: Option<&BridgeSnapshot>) -> Option<&DeviceBattery> { + let snapshot = snapshot?; + snapshot + .batteries + .iter() + .find(|battery| !battery.stale) + .filter(|battery| { + battery.dpi.is_some() + || battery.polling_rate_hz.is_some() + || battery.switch_mode.is_some() + }) } #[derive(Clone, Copy)] @@ -366,6 +401,85 @@ enum TrayAction { Exit, } +/// The battery display currently swapped into the tray icon: `None` keeps +/// the static OpenMouse logo. Every field here feeds the drawing or the +/// tooltip, so comparing against the next snapshot redraws exactly when +/// the level, charge state, estimate, or size changed — and stops the +/// per-second updates from churning the icon when nothing did. +#[derive(Clone, PartialEq, Eq)] +struct TrayBattery { + device_name: String, + percent: u8, + charging: bool, + stale: bool, + /// The Segoe glyph's ink, from `battery_ink` (green charged/charging, + /// amber mid, alert red when low, gray when stale). + ink: Color32, + minutes_remaining: Option, + /// The notification area's icon size in physical pixels at the current + /// DPI; Windows reports it, so a display-scaling change redraws. + size: u32, + /// What fills the square: the battery glyph or the percentage number + /// (the control panel's tray-icon style). + style: platform::TrayBatteryStyle, +} + +/// The battery glyph's ink, by charge state: green while charging or above +/// half, amber through the mid band, the alert red at or below the +/// low-battery threshold, and gray when the reading is stale. +fn battery_ink(percent: u8, charging: bool, stale: bool, threshold: u8) -> Color32 { + use crate::native_ui::{ACCENT, DANGER, MUTED, WARNING}; + if stale { + MUTED + } else if charging { + ACCENT + } else if percent <= threshold { + DANGER + } else if percent <= 50 { + WARNING + } else { + ACCENT + } +} + +/// Renders the live tray icon from a cached battery display: either the +/// Segoe Fluent battery glyph (the charging variant with the bolt when +/// plugged in) or the percentage number, per the panel's tray-icon style, +/// tinted by charge state — green charged/charging, amber mid, red low, +/// gray stale. +fn battery_tray_icon(battery: &TrayBattery) -> Result { + let (rgba, width, height) = platform::battery_icon_rgba( + battery.percent, + battery.charging, + battery.style, + battery.size, + (battery.ink.r(), battery.ink.g(), battery.ink.b()), + )?; + Icon::from_rgba(rgba, width, height).context("could not create the battery tray icon") +} + +/// Formats a rough duration for the tooltip: "45 m", "3 h 20 m", "1 d 4 h". +fn remaining_text(minutes: u32) -> String { + let days = minutes / (24 * 60); + let hours = minutes % (24 * 60) / 60; + let minutes = minutes % 60; + if days > 0 { + if hours > 0 { + format!("{days} d {hours} h") + } else { + format!("{days} d") + } + } else if hours > 0 { + if minutes > 0 { + format!("{hours} h {minutes} m") + } else { + format!("{hours} h") + } + } else { + format!("{minutes} m") + } +} + fn tray_menu_action(open: bool, restart: bool, quit: bool) -> Option { if open { Some(TrayAction::ShowWindow) @@ -453,6 +567,33 @@ impl TrayState { Ok(()) } + /// Swaps the tray icon for the live battery display: the battery glyph + /// drawn from `battery`'s level, theme, and state colors, with a tooltip + /// that spells the percentage out on hover. The previous icon's HICON is + /// destroyed by the tray backend when the new one replaces it, so + /// redrawing never leaks GDI handles. + fn set_battery_display(&mut self, battery: &TrayBattery, tooltip: &str) -> Result<()> { + let icon = battery_tray_icon(battery)?; + self.icon + .set_icon(Some(icon)) + .context("could not swap in the battery tray icon")?; + self.icon + .set_tooltip(Some(tooltip.to_owned())) + .context("could not update the battery tooltip")?; + Ok(()) + } + + /// Restores the static OpenMouse logo and its plain tooltip. + fn set_default_display(&mut self) -> Result<()> { + self.icon + .set_icon(Some(tray_icon()?)) + .context("could not restore the OpenMouse tray icon")?; + self.icon + .set_tooltip(Some("OpenMouse Bridge")) + .context("could not restore the tray tooltip")?; + Ok(()) + } + fn next_action(&self) -> Option { self.events.try_recv().ok() } @@ -477,6 +618,9 @@ struct TrayApp { keep_running: Arc, autostart: bool, battery_threshold: u8, + tray_battery: Option, + tray_style: platform::TrayBatteryStyle, + hybrid_mode: bool, automatic_updates: bool, notification_sound: bool, notification_volume: u8, @@ -543,6 +687,9 @@ impl TrayApp { keep_running, autostart: platform::autostart_enabled(), battery_threshold: 20, + tray_battery: None, + tray_style: platform::TrayBatteryStyle::Glyph, + hybrid_mode: true, automatic_updates: false, notification_sound: true, notification_volume: 60, @@ -600,6 +747,9 @@ impl TrayApp { while let Ok(snapshot) = self.snapshots.try_recv() { self.autostart = snapshot.autostart_enabled; self.battery_threshold = snapshot.battery_threshold_percent; + self.tray_style = + platform::TrayBatteryStyle::from_config_str(&snapshot.tray_battery_style); + self.hybrid_mode = snapshot.hybrid_mode; self.automatic_updates = snapshot.automatic_updates; self.notification_sound = snapshot.notification_sound; if !self.adjusting_volume { @@ -607,6 +757,74 @@ impl TrayApp { } self.snapshot = Some(snapshot); } + self.refresh_tray_battery_display(false); + } + + /// Refreshes the tray icon: a live battery glyph showing the mouse's + /// charge (or the percentage number, per the panel's tray-icon style) + /// while any battery is known, the static OpenMouse logo otherwise. + /// Redraws only when the display would change unless `force` — used + /// after icon recreation, when the fresh icon is the plain logo again. + fn refresh_tray_battery_display(&mut self, force: bool) { + let battery = self.snapshot.as_ref().and_then(|snapshot| { + snapshot + .batteries + .iter() + .find(|battery| !battery.stale) + .or_else(|| snapshot.batteries.first()) + }); + let shown = battery.map(|battery| TrayBattery { + device_name: battery.device_name.clone(), + percent: battery.percent.min(100), + charging: battery.charging, + stale: battery.stale, + ink: battery_ink( + battery.percent.min(100), + battery.charging, + battery.stale, + self.battery_threshold, + ), + minutes_remaining: battery.minutes_remaining, + size: platform::tray_icon_size(), + style: self.tray_style, + }); + if !force && shown == self.tray_battery { + return; + } + let outcome = match &shown { + Some(battery) => { + let status = match (battery.charging, battery.stale) { + (true, _) => " · Charging", + (false, true) => " · Not updating", + (false, false) => "", + }; + // Windows' tooltip buffer tops out at 128 UTF-16 units, and + // device names can be long. + let eta = battery.minutes_remaining.map(|minutes| { + if battery.charging { + format!("full in {}", remaining_text(minutes)) + } else { + format!("{} left", remaining_text(minutes)) + } + }); + let tooltip = match eta { + Some(eta) => { + format!( + "{} — {}%{status} · {eta}", + battery.device_name, battery.percent + ) + } + None => format!("{} — {}%{status}", battery.device_name, battery.percent), + }; + self.tray + .set_battery_display(battery, &tooltip.chars().take(120).collect::()) + } + None => self.tray.set_default_display(), + }; + match outcome { + Ok(()) => self.tray_battery = shown, + Err(error) => tracing::warn!(%error, "Could not update the tray battery display"), + } } /// Shows pending notices and advances the banner's fade. @@ -768,6 +986,8 @@ impl TrayApp { if let Err(error) = self.tray.recreate_icon() { tracing::error!(%error, "could not recreate the tray icon after resume"); } + // The fresh icon is the plain logo; put the battery display back. + self.refresh_tray_battery_display(true); } fn titlebar(&mut self, ui: &mut egui::Ui) { @@ -920,6 +1140,19 @@ impl TrayApp { if let Some(rate) = profile.settings.polling_rate_hz { values.push(format!("{rate} Hz")); } if !values.is_empty() { ui.label(RichText::new(format!("Saved: {}", values.join(" · "))).size(12.0).color(MUTED)); } } + // The mouse's own onboard-memory settings, read natively + // wherever its driver can see them — the details someone + // changing DPI or polling rate actually needs, profile or + // no profile. + if let Some(device) = device_summary_battery(self.snapshot.as_ref()) { + let mut values = Vec::new(); + if let Some(dpi) = device.dpi { values.push(format!("{dpi} DPI")); } + if let Some(rate) = device.polling_rate_hz { values.push(format!("{rate} Hz")); } + if let Some(mode) = &device.switch_mode { values.push(mode.clone()); } + if !values.is_empty() { + ui.label(RichText::new(format!("Onboard: {}", values.join(" · "))).size(12.0).color(MUTED)); + } + } if self.snapshot.as_ref().is_some_and(|snapshot| snapshot.native_automation_error.is_some() || snapshot.pending_native_restores > 0) { ui.label(RichText::new(profile_label(self.snapshot.as_ref())).size(12.0).color(DANGER)); } @@ -969,6 +1202,82 @@ impl TrayApp { } }); ui.add_space(6.0); + n::panel(ui, false, 8, |ui| { + ui.set_width(ui.available_width()); + ui.label( + RichText::new("Device mode") + .size(12.0) + .strong() + .color(MUTED), + ); + let mut enabled = self.hybrid_mode; + if n::switch_row(ui, "Hybrid", &mut enabled) + .on_hover_text( + "While the web panel is closed, Bridge applies the mouse's stored settings and reads the battery itself.", + ) + .changed() + && self.send_command( + BridgeCommand::SetHybridMode(enabled), + "Bridge settings service is unavailable", + ) + { + self.hybrid_mode = enabled; + } + ui.label( + RichText::new(if self.hybrid_mode { + "While the web panel is closed, Bridge applies the mouse's \ + stored settings and reads the battery itself" + } else { + "Only the web panel changes the mouse" + }) + .size(11.0) + .color(MUTED), + ); + divider(ui); + let mut selected_style = self.tray_style; + row(ui, "Battery display", |ui| { + let choice = egui::ComboBox::from_id_salt("tray-battery-style") + .width(110.0) + .selected_text( + RichText::new(match self.tray_style { + platform::TrayBatteryStyle::Glyph => "Battery glyph", + platform::TrayBatteryStyle::Percentage => "Percentage", + }) + .size(12.0), + ) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut selected_style, + platform::TrayBatteryStyle::Glyph, + "Battery glyph", + ); + ui.selectable_value( + &mut selected_style, + platform::TrayBatteryStyle::Percentage, + "Percentage", + ); + }); + choice.response.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::ComboBox, + ui.is_enabled(), + "Battery display", + ) + }); + }); + if selected_style != self.tray_style + && self.send_command( + BridgeCommand::SetTrayBatteryStyle(selected_style), + "Bridge settings service is unavailable", + ) + { + self.tray_style = selected_style; + // Redraw the tray at once; the service's next snapshot + // only confirms what is already on screen. + self.refresh_tray_battery_display(true); + } + }); + ui.add_space(6.0); n::panel(ui, false, 8, |ui| { ui.set_width(ui.available_width()); ui.label( @@ -1238,11 +1547,13 @@ impl TrayApp { .as_ref() .and_then(|s| s.active_profile.as_ref()) .is_some_and(|p| p.settings.dpi.is_some() || p.settings.polling_rate_hz.is_some()); + let has_summary = device_summary_battery(self.snapshot.as_ref()).is_some(); let height = panel_height( self.view, self.details_open, has_values, update_is_notice(&self.update_state), + has_summary, ); if height != self.requested_height { let origin = context @@ -1627,6 +1938,19 @@ fn run_server( } } } + BridgeCommand::SetHybridMode(enabled) => { + if let Err(error) = command_service.set_hybrid_mode(enabled).await { + tracing::error!(%error, "could not save Bridge settings"); + } + } + BridgeCommand::SetTrayBatteryStyle(style) => { + if let Err(error) = command_service + .set_tray_battery_style(style.as_config_str()) + .await + { + tracing::error!(%error, "could not save Bridge settings"); + } + } BridgeCommand::CheckForUpdates => { check_for_updates(false, &update_events).await; } @@ -1974,17 +2298,25 @@ mod tests { #[test] fn content_growth_stays_bounded_and_settings_has_its_own_budget() { - assert_eq!(panel_height(View::Home, false, false, false), 280.0); - assert_eq!(panel_height(View::Home, false, true, false), 298.0); - assert_eq!(panel_height(View::Home, false, true, true), 338.0); - assert_eq!(panel_height(View::Home, true, false, false), 340.0); - assert_eq!(panel_height(View::Settings, true, true, true), 420.0); + assert_eq!(panel_height(View::Home, false, false, false, false), 280.0); + assert_eq!(panel_height(View::Home, false, true, false, false), 298.0); + assert_eq!(panel_height(View::Home, false, true, true, false), 338.0); + assert_eq!(panel_height(View::Home, true, false, false, false), 340.0); + assert_eq!(panel_height(View::Settings, true, true, true, false), 420.0); + } + + #[test] + fn device_summary_rows_grow_the_home_view() { + assert_eq!(panel_height(View::Home, false, false, false, true), 302.0); + assert_eq!(panel_height(View::Home, false, true, false, true), 320.0); + // The details view scrolls, so its budget stays fixed. + assert_eq!(panel_height(View::Home, true, false, false, true), 340.0); } #[test] fn idle_window_is_a_compact_companion_not_a_settings_sheet() { assert_eq!(WINDOW_WIDTH, 360.0); - assert!(panel_height(View::Home, false, false, false) <= 340.0); + assert!(panel_height(View::Home, false, false, false, false) <= 340.0); } #[test] @@ -2025,6 +2357,9 @@ mod tests { games: vec![], tracked_game_count: 0, battery_threshold_percent: 20, + tray_battery_style: "glyph".to_owned(), + hybrid_mode: true, + device_mode: "onboard".to_owned(), autostart_enabled: false, automatic_updates: false, notification_sound: true, @@ -2053,6 +2388,10 @@ mod tests { percent: 60, charging: false, stale: true, + minutes_remaining: None, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }); assert_eq!(device_label(Some(&snapshot)), "No mouse session"); } @@ -2079,6 +2418,10 @@ mod tests { percent: 60, charging: true, stale: true, + minutes_remaining: None, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }; assert_eq!(battery_text(&battery), "60% · Last known (was charging)"); } @@ -2162,6 +2505,10 @@ mod tests { percent: 42, charging: false, stale: false, + minutes_remaining: None, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }; assert_eq!(battery_text(&battery), "42%"); battery.charging = true; diff --git a/src/drivers/mod.rs b/src/drivers/mod.rs index 9888a14..3b10217 100644 --- a/src/drivers/mod.rs +++ b/src/drivers/mod.rs @@ -23,7 +23,148 @@ pub mod delux; mod native_hid; pub mod pulsar; -pub use native_hid::{NativeBattery, spawn_button_listener}; +pub use native_hid::{NativeBattery, NativeStatus, spawn_button_listener}; + +/// A mouse Bridge discovered by itself, by scanning the HID bus for vendor +/// ids its drivers recognize — no saved profile required. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DetectedMouse { + /// Stable profile-style id, `":"` — the same + /// `":"` prefix convention the OpenMouse web app writes. + pub device_id: String, + /// Human-readable product name from the HID descriptor. + pub name: String, +} + +/// Brand keys and vendor ids, mirroring the helper's `native-hid/src/brands.mjs` +/// (the same drivers the web panel drives, and the same normalization +/// `apply.mjs` gives `device.id` prefixes: trim + lowercase). When several +/// brands share a vendor id, the entry names the brand whose driver class +/// list covers the most sibling hardware — Delux's list includes the Attack +/// Shark and Lamzu clients — or `mouse-protocol`'s registry.ts first match; +/// the loser's devices merely fail their silent battery probe later. +const KNOWN_BRANDS: &[(&str, &[u16])] = &[ + ("Delux", &[0x1d57, 0x248a, 0x373e]), + ("Logitech", &[0x046d]), + ("Razer", &[0x1532]), + ("Zaunkoenig", &[0x0483]), + ("Finalmouse", &[0x361d]), + ("Endgame Gear", &[0x3367]), + ("Teevolution", &[0x3554]), + ("WLMouse", &[0x36a7]), + ("moddoMOUSE", &[0x2fe3]), + ("Ninjutso", &[0x093a, 0x1915]), + ("ATK", &[0x373b]), + ("Attack Shark", &[0x25a7]), + ("Keychron", &[0x3434]), + ("Fantech", &[0x3151]), + ("Wooting", &[0x31e3]), + ("Microsoft", &[0x045e]), + ("WALLHACK", &[0x3879, 0x1caa]), +]; + +/// Logitech is special: every Logitech-branded device (keyboards, headsets) +/// shares vendor id 0x046d and the HID++ transport (`mouse-protocol`'s +/// `drivers/logitech/hidpp.ts` has the same limitation — "whether it is a +/// *mouse* cannot be told from descriptors"), so discovery keeps the HID++ +/// collections — short/long reports on usage page 0xFF00, Bluetooth on +/// 0xFF43 — and a keyboard merely fails its battery probe silently later. +const LOGITECH_VENDOR_ID: u16 = 0x046d; +const HIDPP_USAGE_PAGE: u16 = 0xff00; +const HIDPP_BLUETOOTH_USAGE_PAGE: u16 = 0xff43; + +/// The brand a discovered HID interface belongs to, or `None` when the +/// interface should be skipped (other vendors' hardware, Logitech non-HID++ +/// interfaces, Noir's undriven receiver PID). +fn discovered_brand(info: &hidapi::DeviceInfo) -> Option<&'static str> { + let vendor_id = info.vendor_id(); + if vendor_id == pulsar::VENDOR_ID { + return Some("Pulsar"); + } + if vendor_id == LOGITECH_VENDOR_ID { + let usage_page = info.usage_page(); + let hidpp_transport = (usage_page == HIDPP_USAGE_PAGE + && matches!(info.usage(), 0x01 | 0x02)) + || usage_page == HIDPP_BLUETOOTH_USAGE_PAGE; + return hidpp_transport.then_some("Logitech"); + } + if vendor_id == 0x3554 { + // Teevolution and VGN share this vendor page; their product ids are + // disjoint (pulsar-hid.ts's CLAIMED_VGN_PRODUCT_IDS). Noir Gear's + // 0xf500 rides it too but has no native driver. + return match info.product_id() { + 0xfb56 | 0xfb57 => Some("VGN"), + 0xf500 => None, + _ => Some("Teevolution"), + }; + } + KNOWN_BRANDS + .iter() + .find(|(_, ids)| ids.contains(&vendor_id)) + .map(|(brand, _)| *brand) +} + +/// Every HID interface on the bus whose vendor id belongs to a brand with a +/// Bridge driver, deduplicated to one entry per physical mouse (several +/// interfaces share the same vendor/product/serial). +/// +/// Deliberately does not open any device — enumeration only — so it is safe +/// to call while a web panel session or another driver operation runs. +pub fn detect_mice() -> Vec { + let api = match hidapi::HidApi::new() { + Ok(api) => api, + Err(error) => { + tracing::warn!(%error, "could not enumerate HID devices"); + return Vec::new(); + } + }; + let mut mice: Vec = Vec::new(); + let mut seen: Vec<(u16, u16, String)> = Vec::new(); + for info in api.device_list() { + let vendor_id = info.vendor_id(); + let Some(brand) = discovered_brand(info) else { + continue; + }; + // One physical mouse exposes several HID interfaces (boot mouse, + // consumer control, vendor config, ...). Dedup on the identity + // triple so the same mouse is not listed three times. + let tail = identity_tail(&( + vendor_id, + info.product_id(), + info.serial_number().unwrap_or_default(), + )); + let identity = (vendor_id, info.product_id(), tail); + if seen.contains(&identity) { + continue; + } + seen.push(identity.clone()); + mice.push(DetectedMouse { + device_id: format!("{brand}:{}", identity.2), + name: product_name(info.product_string().unwrap_or_default(), brand), + }); + } + mice +} + +/// The stable per-mouse tail of a discovered device id: the serial number +/// when the device reports one, else `vid:pid`. +fn identity_tail(identity: &(u16, u16, &str)) -> String { + if identity.2.is_empty() { + format!("{:04x}:{:04x}", identity.0, identity.1) + } else { + identity.2.to_owned() + } +} + +/// The HID product string, or the brand name when the device reports none. +fn product_name(product_string: &str, brand: &str) -> String { + let trimmed = product_string.trim(); + if trimmed.is_empty() { + format!("{brand} Mouse") + } else { + trimmed.to_owned() + } +} /// Serializes Bridge's own device access. A profile switch and a battery read /// can start at the same moment (both run at startup), and on macOS the second @@ -116,20 +257,20 @@ pub(crate) fn reconcile_automatic( ) } -/// Reads the battery of the mouse `device_id` names (`":"`) over -/// native HID. `Ok(None)` means Bridge has no battery reader for the brand or -/// the mouse reports no battery; `Err` means a driver exists but the mouse -/// did not answer (e.g. asleep or disconnected). -/// -/// The Rust Pulsar driver only writes settings, so Pulsar has no reader yet. -pub fn read_battery(device_id: &str) -> Result> { +/// Reads the onboard-memory state of the mouse `device_id` names +/// (`":"`) over native HID — battery, and the current DPI / +/// polling rate / switch mode wherever the driver can read them. +/// `Ok(None)` means Bridge has no reader for the brand or the mouse reports +/// no battery; `Err` means a driver exists but the mouse did not answer +/// (e.g. asleep or disconnected). +pub fn read_status(device_id: &str) -> Result> { let brand = device_id.split(':').next().unwrap_or_default(); - if brand.eq_ignore_ascii_case(pulsar::BRAND) { - return Ok(None); - } let _access = device_access(); ensure_native_ownership()?; - native_hid::read_battery(brand) + if brand.eq_ignore_ascii_case(pulsar::BRAND) { + return pulsar::read_status(); + } + native_hid::read_status(brand) } /// local Bridge. Native-only devices use this path when WebHID cannot access @@ -440,4 +581,81 @@ mod tests { "expected an error with no Razer mouse connected, got {outcome:?}" ); } + + /// Discovery must see every vendor id the helper can drive, and every + /// brand discovery writes into `device.id` must normalize to a helper + /// brand key — otherwise a mouse is discovered but its battery or + /// settings can never be read. Parses `native-hid/src/brands.mjs` (the + /// helper's own table) so the two cannot drift apart silently. + #[test] + fn discovery_table_matches_the_native_hid_helper_brand_table() { + let source = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/native-hid/src/brands.mjs" + )) + .expect("the native-hid checkout ships next to Cargo.toml"); + let mut after_table = false; + let mut helper_keys = Vec::new(); + let mut helper_vendor_ids = Vec::new(); + for line in source.lines() { + if line.starts_with("export const BRAND_DRIVERS") { + after_table = true; + continue; + } + if !after_table { + continue; + } + if let Some(start) = line.find("vendorIds: [") { + let rest = &line[start + "vendorIds: [".len()..]; + let end = rest.find(']').expect("vendorIds lists close on their line"); + for value in rest[..end].split(',') { + let value = value.trim(); + if value.is_empty() { + continue; + } + helper_vendor_ids.push( + u16::from_str_radix(value.trim_start_matches("0x"), 16) + .expect("brands.mjs vendor ids are hex"), + ); + } + } else if let Some(key) = line.strip_suffix(": {") { + helper_keys.push(key.trim().trim_matches('"').to_owned()); + } + } + assert!( + !helper_vendor_ids.is_empty(), + "parsed no vendor ids — did brands.mjs's format change?" + ); + let discoverable = |id: u16| { + // The special-cased vendor ids in `discovered_brand`: the shared + // Teevolution/VGN page, Logitech's HID++ transport, and Pulsar's + // own Rust-driver vendor id. + [pulsar::VENDOR_ID, 0x3554, LOGITECH_VENDOR_ID].contains(&id) + || delux::SUPPORTED_VENDOR_IDS.contains(&id) + || KNOWN_BRANDS.iter().any(|(_, ids)| ids.contains(&id)) + }; + for id in helper_vendor_ids { + assert!( + discoverable(id), + "vendor id 0x{id:04x} is in brands.mjs but discovery cannot see it" + ); + } + for (brand, _) in KNOWN_BRANDS { + let key = brand.trim().to_lowercase(); + assert!( + helper_keys.contains(&key), + "discovery brand {brand:?} has no {key:?} key in brands.mjs" + ); + } + } + + #[test] + #[ignore = "prints what discovery sees on live hardware; needs a supported mouse connected"] + fn discovery_lists_the_connected_mice() { + let mice = detect_mice(); + for mouse in &mice { + println!("{mouse:?}"); + } + assert!(!mice.is_empty(), "discovery found no mice"); + } } diff --git a/src/drivers/native_hid.rs b/src/drivers/native_hid.rs index 131d344..8a0514f 100644 --- a/src/drivers/native_hid.rs +++ b/src/drivers/native_hid.rs @@ -37,6 +37,11 @@ struct ApplyRequest<'a> { dpi: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "pollingRateHz")] polling_rate_hz: Option, + /// The profile's opaque panel settings (`ProfileSettings.snapshot`, + /// e.g. `{"lightforceSwitchMode":"Optical"}`). The helper applies the + /// fields its driver supports and skips the rest. + #[serde(skip_serializing_if = "Option::is_none")] + settings: Option<&'a serde_json::Value>, } #[derive(Serialize)] @@ -52,13 +57,49 @@ struct StatusRequest<'a> { struct StatusReply { battery_percent: Option, battery_state: Option, + /// The driver's own model name (`MouseStatus.name`), when the driver + /// reports one — better than the receiver-level name discovery gets from + /// the HID descriptor. + device_name: Option, + /// The settings currently in the device's onboard memory, when the + /// driver can read them. `dpi` is a number or a per-stage list in + /// `MouseStatus`; anything else parses as `None`. + dpi: Option, + polling_rate_hz: Option, + switch_mode: Option, } /// A battery level read from the mouse over native HID. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct NativeBattery { pub percent: u8, pub charging: bool, + /// The model name the driver itself reported, when it did. + pub reported_name: Option, +} + +/// Everything the mouse reports about its own onboard-memory state: the +/// battery plus the settings currently in effect. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NativeStatus { + pub battery: NativeBattery, + pub dpi: Option, + pub polling_rate_hz: Option, + /// Logitech's LightForce switch mode; `None` for brands without one. + pub switch_mode: Option, +} + +/// `MouseStatus.dpi` is either one number or a per-stage list; the stages +/// have no reliable "active" marker, so a list collapses to its first +/// stage — the one most drivers treat as the default. +fn coerce_dpi(value: Option<&serde_json::Value>) -> Option { + match value? { + serde_json::Value::Number(number) => number.as_u64().map(|dpi| dpi as u32), + serde_json::Value::Array(stages) => stages + .iter() + .find_map(|stage| stage.as_u64().map(|dpi| dpi as u32)), + _ => None, + } } /// Pushes a profile's DPI/polling rate to the mouse via the Node helper. @@ -68,10 +109,11 @@ pub struct NativeBattery { /// apply itself failed (including the helper or Node.js being unavailable). pub fn apply(profile: &ApplicationProfile) -> Result { let brand = profile.device.id.split(':').next().unwrap_or_default(); - apply_settings( + apply_settings_with_snapshot( brand, profile.settings.dpi, profile.settings.polling_rate_hz, + profile.settings.snapshot.as_ref(), ) } @@ -79,18 +121,28 @@ pub fn apply(profile: &ApplicationProfile) -> Result { /// the exact helper path used by automatic profiles, so native device control /// and profile switching cannot drift into separate protocol implementations. pub fn apply_settings(brand: &str, dpi: Option, polling_rate_hz: Option) -> Result { + apply_settings_with_snapshot(brand, dpi, polling_rate_hz, None) +} + +fn apply_settings_with_snapshot( + brand: &str, + dpi: Option, + polling_rate_hz: Option, + settings: Option<&serde_json::Value>, +) -> Result { let request = ApplyRequest { brand, dpi, polling_rate_hz, + settings, }; Ok(run_helper(&request)?.is_some()) } -/// Reads the battery of a `brand` mouse via the Node helper. `Ok(None)` means +/// Reads a `brand` mouse's status via the Node helper. `Ok(None)` means /// the helper has no driver for the brand or the mouse reports no battery /// (a wired mouse); `Err` means a driver exists but no mouse answered. -pub fn read_battery(brand: &str) -> Result> { +pub fn read_status(brand: &str) -> Result> { let request = StatusRequest { brand, action: "status", @@ -100,10 +152,22 @@ pub fn read_battery(brand: &str) -> Result> { }; let reply: StatusReply = serde_json::from_slice(&stdout) .context("the native-hid helper returned an unreadable status")?; - Ok(parse_battery(reply)) + Ok(parse_status(reply)) } -fn parse_battery(reply: StatusReply) -> Option { +fn parse_status(reply: StatusReply) -> Option { + let battery = parse_battery(&reply)?; + Some(NativeStatus { + battery, + dpi: coerce_dpi(reply.dpi.as_ref()), + polling_rate_hz: reply + .polling_rate_hz + .and_then(|rate| u32::try_from(rate.round() as i64).ok()), + switch_mode: reply.switch_mode.filter(|mode| !mode.trim().is_empty()), + }) +} + +fn parse_battery(reply: &StatusReply) -> Option { let percent = reply.battery_percent?; if !percent.is_finite() { return None; @@ -112,7 +176,12 @@ fn parse_battery(reply: StatusReply) -> Option { percent: percent.round().clamp(0.0, 100.0) as u8, charging: reply .battery_state + .as_ref() .is_some_and(|state| state.starts_with("Charging")), + reported_name: reply + .device_name + .clone() + .filter(|name| !name.trim().is_empty()), }) } @@ -362,29 +431,105 @@ mod tests { StatusReply { battery_percent: percent, battery_state: state.map(str::to_owned), + device_name: None, + dpi: None, + polling_rate_hz: None, + switch_mode: None, + } + } + + fn named_reply(name: &str) -> StatusReply { + StatusReply { + battery_percent: Some(41.0), + battery_state: Some("Discharging".into()), + device_name: Some(name.to_owned()), + dpi: None, + polling_rate_hz: None, + switch_mode: None, } } #[test] fn parses_battery_from_status() { assert_eq!( - parse_battery(reply(Some(41.0), Some("Discharging"))), - Some(NativeBattery { - percent: 41, - charging: false + parse_status(reply(Some(41.0), Some("Discharging"))), + Some(NativeStatus { + battery: NativeBattery { + percent: 41, + charging: false, + reported_name: None, + }, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) ); assert_eq!( - parse_battery(reply(Some(87.6), Some("Charging slowly"))), - Some(NativeBattery { - percent: 88, - charging: true + parse_status(reply(Some(87.6), Some("Charging slowly"))), + Some(NativeStatus { + battery: NativeBattery { + percent: 88, + charging: true, + reported_name: None, + }, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) ); - assert_eq!(parse_battery(reply(None, Some("Unknown"))), None); + assert_eq!(parse_status(reply(None, Some("Unknown"))), None); assert_eq!( - parse_battery(reply(Some(140.0), None)).map(|battery| battery.percent), + parse_status(reply(Some(140.0), None)).map(|status| status.battery.percent), Some(100) ); + // The driver's model name rides along for better tray labels. + assert_eq!( + parse_status(named_reply("G Pro X Superlight 2")), + Some(NativeStatus { + battery: NativeBattery { + percent: 41, + charging: false, + reported_name: Some("G Pro X Superlight 2".into()), + }, + dpi: None, + polling_rate_hz: None, + switch_mode: None, + }) + ); + // A blank name is treated as no name. + assert_eq!( + parse_status(named_reply(" ")).and_then(|status| status.battery.reported_name), + None + ); + } + + #[test] + fn parses_the_onboard_settings_from_status() { + let mut logitech = reply(Some(81.0), Some("Discharging")); + logitech.device_name = Some("G Pro X Superlight 2".into()); + logitech.dpi = Some(serde_json::json!(800)); + logitech.polling_rate_hz = Some(1000.0); + logitech.switch_mode = Some("Optical".into()); + assert_eq!( + parse_status(logitech), + Some(NativeStatus { + battery: NativeBattery { + percent: 81, + charging: false, + reported_name: Some("G Pro X Superlight 2".into()), + }, + dpi: Some(800), + polling_rate_hz: Some(1000), + switch_mode: Some("Optical".into()), + }) + ); + // Drivers that report a stage list collapse to the first stage, and + // blank switch modes read as none. + let mut staged = reply(Some(81.0), None); + staged.dpi = Some(serde_json::json!([400, 800, 1600])); + staged.switch_mode = Some(" ".into()); + let status = parse_status(staged).expect("staged status parses"); + assert_eq!(status.dpi, Some(400)); + assert_eq!(status.switch_mode, None); } } diff --git a/src/drivers/pulsar.rs b/src/drivers/pulsar.rs index a4f0b2e..89a1843 100644 --- a/src/drivers/pulsar.rs +++ b/src/drivers/pulsar.rs @@ -17,6 +17,7 @@ use std::{ use anyhow::{Context, Result, anyhow, bail}; use hidapi::HidApi; +use super::{NativeBattery, NativeStatus}; use crate::config::ApplicationProfile; /// This driver's slot in `crate::drivers`' brand registry — matches the @@ -37,6 +38,43 @@ pub fn apply(profile: &ApplicationProfile) -> Result<()> { Ok(()) } +/// Opens the connected Pulsar mouse and reads its onboard-memory state — +/// the battery via the same `batteryLevel` query (command `0x04`, percent in +/// response byte 5, charge flag in byte 6) the WebHID driver's `readStatus` +/// uses, plus the active DPI stage and polling rate from flash, all inside +/// one host-control handshake. `Ok(None)` means the mouse answered but +/// reported no battery (byte 5 above 100). +pub fn read_status() -> Result> { + let device = PulsarDevice::open()?; + device.with_device_control(|| { + // The same flash reads `set_dpi`/`set_polling_rate` confirm their + // writes with, so the decode paths are hardware-verified. + let polling_rate_hz = decode_polling_rate(device.read_flash(flash::REPORT_RATE, 2)?[0]); + let stage = device.read_flash(flash::CURRENT_DPI, 2)?[0].min(7) as u16; + let dpi = decode_dpi(&device.read_flash(flash::DPI_VALUES + stage * 4, 4)?); + let response = device.query(command::BATTERY_LEVEL, &[])?; + assert_accepted(&response, "battery query")?; + let percent = response[5]; + if percent > 100 { + return Ok(None); + } + Ok(Some(NativeStatus { + battery: NativeBattery { + percent, + charging: response[6] == 1, + // The report-8 battery query carries no model name; the HID + // product string discovery already read is the best + // available. + reported_name: None, + }, + dpi, + polling_rate_hz, + // The report-8 protocol has no LightForce equivalent. + switch_mode: None, + })) + }) +} + /// Real Pulsar-branded mice and dongles. The Pulsar 4K Wireless Receiver /// shares a report-8 16-byte protocol but enumerates under a different, /// shared vendor id (0x3554) that other brands also use — out of scope here. @@ -49,6 +87,7 @@ const EXCHANGE_TIMEOUT: Duration = Duration::from_millis(1200); mod command { pub const ENCRYPTION_DATA: u8 = 0x01; pub const DEVICE_ONLINE: u8 = 0x03; + pub const BATTERY_LEVEL: u8 = 0x04; pub const WRITE_FLASH_DATA: u8 = 0x07; pub const READ_FLASH_DATA: u8 = 0x08; } diff --git a/src/main.rs b/src/main.rs index 085c4e5..ceebbe1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,6 +64,7 @@ async fn run() -> Result<()> { // application icons over the API, so always keep them extracted. service.start_game_monitor(Arc::new(AtomicBool::new(true))); service.start_battery_monitor(); + service.start_mode_monitor(); let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), BRIDGE_PORT); let listener = TcpListener::bind(address) .await diff --git a/src/platform/mod.rs b/src/platform/mod.rs index e985308..9452878 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -15,7 +15,10 @@ pub mod linux; mod portable; #[cfg(target_os = "windows")] -pub use windows::{autostart_enabled, linux_distribution, platform_name, set_autostart}; +pub use windows::{ + autostart_enabled, battery_icon_rgba, light_tray_theme, linux_distribution, platform_name, + set_autostart, tray_icon_size, +}; #[cfg(target_os = "macos")] pub use macos::{autostart_enabled, linux_distribution, platform_name, set_autostart}; @@ -26,6 +29,115 @@ pub use linux::{autostart_enabled, linux_distribution, platform_name, set_autost #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] pub use portable::{autostart_enabled, linux_distribution, platform_name, set_autostart}; +/// The pixel size a tray icon should be drawn at, in physical pixels. +/// Windows reports the notification area's small-icon size for the current +/// system DPI (16 at 100%, 20/24/32 as scaling rises); other desktops get a +/// fixed size that reads well in menu bars and appindicator hosts. +#[cfg(not(target_os = "windows"))] +pub fn tray_icon_size() -> u32 { + 22 +} + +/// Whether the shell paints its surfaces light. Only Windows reports it; +/// macOS menu bars and Linux appindicators are dark in practice. +#[cfg(not(target_os = "windows"))] +pub fn light_tray_theme() -> bool { + false +} + +/// What the tray battery icon shows. The Windows 11 taskbar only renders +/// square tray icons correctly, so the glyph and the number are two +/// mutually exclusive styles chosen in the control panel, not one wide +/// display. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrayBatteryStyle { + /// The native Segoe battery glyph alone, filling the square icon. + Glyph, + /// The percentage number alone in bold text, filling the square icon. + Percentage, +} + +impl TrayBatteryStyle { + /// The config value this style is stored as. + pub fn as_config_str(self) -> &'static str { + match self { + Self::Glyph => "glyph", + Self::Percentage => "percentage", + } + } + + /// Reads a stored config value; anything unknown falls back to the + /// default glyph. + pub fn from_config_str(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "percentage" => Self::Percentage, + _ => Self::Glyph, + } + } +} + +/// Renders the tray battery display as an RGBA buffer in `ink`. Windows +/// draws either the Segoe Fluent battery glyph or the percentage number in +/// bold UI text, per `style`; other desktops get a simple monochrome +/// fallback: a bold outline battery with the charge fill, no anti-aliasing +/// (the style is ignored there). +#[cfg(not(target_os = "windows"))] +pub fn battery_icon_rgba( + percent: u8, + charging: bool, + style: TrayBatteryStyle, + size: u32, + ink: (u8, u8, u8), +) -> Result<(Vec, u32, u32)> { + let _ = style; + let size = size.max(8); + let mut rgba = vec![0u8; (size * size * 4) as usize]; + let mut put = |x0: u32, y0: u32, x1: u32, y1: u32| { + for y in y0..y1.min(size) { + for x in x0..x1.min(size) { + let index = ((y * size + x) * 4) as usize; + rgba[index] = ink.0; + rgba[index + 1] = ink.1; + rgba[index + 2] = ink.2; + rgba[index + 3] = u8::MAX; + } + } + }; + let stroke = (size / 10).max(1); + let body_height = size * 2 / 3; + let body_top = (size - body_height) / 2; + let body_bottom = body_top + body_height; + let nub_width = (size / 16).max(1); + let body_width = size.saturating_sub(nub_width + 2); + // The shell, a nub on the right, and the charge fill inside. + put(0, body_top, body_width, body_top + stroke); + put(0, body_bottom - stroke, body_width, body_bottom); + put(0, body_top, stroke, body_bottom); + put(body_width - stroke, body_top, body_width, body_bottom); + put( + body_width + 1, + size / 3, + body_width + 1 + nub_width, + size * 2 / 3, + ); + let fill = if charging { + 100 + } else { + u32::from(percent.min(100)) + }; + let inner_x0 = stroke; + let inner_x1 = body_width - stroke; + let inner_width = inner_x1 - inner_x0; + let fill_columns = fill * inner_width / 100; + put( + inner_x0, + body_top + stroke, + inner_x0 + fill_columns, + body_bottom - stroke, + ); + Ok((rgba, size, size)) +} + pub fn notify(summary: &str, body: &str) -> Result<()> { // notify-rust resolves a bundle identifier on macOS. Run as a bare binary // (e.g. `cargo run`) there is none, and macOS pops a blocking "Choose diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 742aed0..3b4270c 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1,14 +1,23 @@ use std::{env, ffi::OsStr, mem::size_of, os::windows::ffi::OsStrExt, ptr::null_mut}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow}; use windows_sys::Win32::{ Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS, WIN32_ERROR}, + Graphics::Gdi::{ + ANTIALIASED_QUALITY, CLIP_DEFAULT_PRECIS, CreateCompatibleDC, CreateFontW, DEFAULT_CHARSET, + DEFAULT_PITCH, DeleteDC, DeleteObject, FF_DONTCARE, FIXED, FW_BOLD, FW_NORMAL, GDI_ERROR, + GGO_GRAY8_BITMAP, GGO_METRICS, GLYPHMETRICS, GetGlyphOutlineW, HDC, HFONT, MAT2, + OUT_TT_PRECIS, SelectObject, + }, System::Registry::{ - HKEY_CURRENT_USER, REG_SZ, RRF_RT_REG_SZ, RegDeleteKeyValueW, RegGetValueW, RegSetKeyValueW, + HKEY_CURRENT_USER, REG_SZ, RRF_RT_REG_DWORD, RRF_RT_REG_SZ, RegDeleteKeyValueW, + RegGetValueW, RegSetKeyValueW, }, + UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON}, }; const RUN_SUBKEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; +const PERSONALIZE_SUBKEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; const VALUE_NAME: &str = "OpenMouse Bridge"; pub const fn platform_name() -> &'static str { @@ -19,6 +28,519 @@ pub fn linux_distribution() -> Option { None } +/// The notification area's icon size in physical pixels at the current +/// system DPI: 16 at 100% scaling, 20/24/32 as the display scaling rises. +pub fn tray_icon_size() -> u32 { + // SAFETY: a trivial metrics query with no side effects. + let width = unsafe { GetSystemMetrics(SM_CXSMICON) }; + width.max(16) as u32 +} + +/// Whether Windows paints system surfaces (the taskbar) light. The taskbar +/// follows `SystemUsesLightTheme`, falling back to `AppsUseLightTheme` when +/// the system value has never been written; anything unreadable means dark, +/// the Windows default. +pub fn light_tray_theme() -> bool { + let subkey = wide(PERSONALIZE_SUBKEY); + for value in ["SystemUsesLightTheme", "AppsUseLightTheme"] { + let value_name = wide(value); + let mut data = 0u32; + let mut byte_count = size_of::() as u32; + let result = unsafe { + RegGetValueW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + value_name.as_ptr(), + RRF_RT_REG_DWORD, + null_mut(), + &mut data as *mut u32 as *mut _, + &mut byte_count, + ) + }; + if result == ERROR_SUCCESS { + return data != 0; + } + } + false +} + +/// Segoe icon-font code points for the battery glyphs, verified against +/// Microsoft's Segoe Fluent Icons documentation: `Battery0`..`Battery9` +/// (E850..E859) cover 0–90% in 10% steps, `Battery10` (E83F) is full, and +/// every level below full has a charging variant carrying the bolt +/// (`BatteryCharging0`..8 at E85A..E862, `BatteryCharging9` at E83E). +pub fn battery_glyph(percent: u8, charging: bool) -> u32 { + let level = u32::from(percent.min(100)) / 10; + if !charging { + return if level == 10 { 0xE83F } else { 0xE850 + level }; + } + match level { + 0..=8 => 0xE85A + level, + 9 => 0xE83E, + // The font has no charging glyph for a full battery; the filled + // Battery10 is what Windows itself falls back to. + _ => 0xE83F, + } +} + +/// Renders the tray battery display as a square `size` × `size` RGBA +/// buffer, tinted with `ink` — the Windows 11 taskbar only renders square +/// tray icons faithfully. The `style` picks what fills the square: the +/// Segoe Fluent battery glyph for the level (the charging variant with the +/// bolt when `charging`), or the percentage number in bold UI text (the +/// `%` is left off — at tray sizes it would shrink the digits to a +/// squint; the tooltip spells the full `NN%` out). Returns the buffer with +/// its width and height. `Segoe Fluent Icons` ships with Windows 11; +/// `Segoe MDL2 Assets` carries the same glyphs on Windows 10. +pub fn battery_icon_rgba( + percent: u8, + charging: bool, + style: super::TrayBatteryStyle, + height: u32, + ink: (u8, u8, u8), +) -> Result<(Vec, u32, u32)> { + let codepoint = battery_glyph(percent, charging); + // The label carries only the digits for the percentage style; the + // glyph style never rasterizes text. + let label: Vec = match style { + super::TrayBatteryStyle::Percentage => { + percent.min(100).to_string().encode_utf16().collect() + } + super::TrayBatteryStyle::Glyph => Vec::new(), + }; + for family in ["Segoe Fluent Icons", "Segoe MDL2 Assets"] { + match render_battery_icon(codepoint, &label, family, height, ink) { + Ok(icon) => return Ok(icon), + // A missing family or glyph means the fallback font should be + // tried; anything else is a real error. + Err(error) if MISSING_GLYPH.with(|flag| flag.get()) => { + tracing::debug!(family, %error, "icon font unusable, trying the next"); + MISSING_GLYPH.with(|flag| flag.set(false)); + } + Err(error) => return Err(error), + } + } + Err(anyhow!( + "no Segoe icon font could render the battery display" + )) +} + +thread_local! { + /// Set by the renderer when the font family or glyph is missing, telling + /// `battery_icon_rgba` to fall through to the next font family. + static MISSING_GLYPH: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// One rasterized glyph: GDI's 8-bit coverage bitmap plus the metrics that +/// place it. +struct Raster { + metrics: GLYPHMETRICS, + coverage: Vec, +} + +impl Raster { + fn width(&self) -> u32 { + self.metrics.gmBlackBoxX + } + + fn height(&self) -> u32 { + self.metrics.gmBlackBoxY + } + + /// The bitmap's bytes per row (DWORD-aligned, as GDI reports). + fn stride(&self) -> usize { + ((self.metrics.gmBlackBoxX + 3) & !3) as usize + } + + /// The coverage compacted to tight rows (no DWORD padding). + fn tight(&self) -> Vec { + let stride = self.stride(); + let width = self.width() as usize; + let mut out = Vec::with_capacity(width * self.height() as usize); + for row in 0..self.height() as usize { + out.extend_from_slice(&self.coverage[row * stride..row * stride + width]); + } + out + } +} + +/// The label's text face: the system UI font in bold, so the percentage +/// reads clearly at tray sizes. +const TEXT_FAMILY: &str = "Segoe UI"; + +fn render_battery_icon( + codepoint: u32, + label: &[u16], + family: &str, + height: u32, + ink: (u8, u8, u8), +) -> Result<(Vec, u32, u32)> { + MISSING_GLYPH.with(|flag| flag.set(false)); + let family = wide(family); + let text_family = wide(TEXT_FAMILY); + // SAFETY: the GDI calls below use their own memory DC, every handle is + // deleted on the way out, and the coverage buffers are sized to what + // `GetGlyphOutlineW` reports. + unsafe { + let dc = CreateCompatibleDC(null_mut()); + if dc.is_null() { + return Err(anyhow!("could not create a memory device context")); + } + let result = + compose_battery_display(dc, codepoint, label, &family, &text_family, height, ink); + DeleteDC(dc); + result + } +} + +fn compose_battery_display( + dc: HDC, + codepoint: u32, + label: &[u16], + family: &[u16], + text_family: &[u16], + height: u32, + ink: (u8, u8, u8), +) -> Result<(Vec, u32, u32)> { + // An empty label is the glyph style: the battery fills the square. + // Otherwise the digits fill it. + if label.is_empty() { + // A landscape glyph in a square icon: fitting its width to all but + // 1px of the canvas — the footprint the old square renderer used — + // is the largest it can be, and the height lands near half. + let battery = fit_and_rasterize( + dc, + codepoint, + family, + FW_NORMAL as i32, + height.saturating_sub(1).max(8), + ) + .ok_or_else(|| anyhow!("could not rasterize the battery glyph U+{codepoint:04X}"))?; + let mut canvas = Canvas { + rgba: vec![0u8; (height * height * 4) as usize], + width: height, + height, + }; + canvas.blit( + (height - battery.width()) / 2, + (height - battery.height()) / 2, + &battery.tight(), + battery.width(), + ink, + ); + return Ok((canvas.rgba, height, height)); + } + + // The digits are drawn as tall as the square allows — a 2px margin — + // and squeezed proportionally when the string is wider than tall + // ("100" is, at every size that matters). + let mut target = height.saturating_sub(4).max(8); + let mut raster = rasterize_label(dc, label, text_family, target) + .ok_or_else(|| anyhow!("could not rasterize the battery label"))?; + if raster.width > height.saturating_sub(2) { + target = + (f64::from(target) * f64::from(height - 2) / f64::from(raster.width)).max(6.0) as u32; + raster = rasterize_label(dc, label, text_family, target) + .ok_or_else(|| anyhow!("could not re-fit the battery label"))?; + } + let mut canvas = Canvas { + rgba: vec![0u8; (height * height * 4) as usize], + width: height, + height, + }; + canvas.blit( + (height - raster.width) / 2, + (height - raster.height) / 2, + &raster.coverage, + raster.width, + ink, + ); + Ok((canvas.rgba, height, height)) +} + +/// A laid-out label: one tight coverage bitmap for the whole string. +struct LabelRaster { + coverage: Vec, + width: u32, + height: u32, +} + +/// Rasterizes the label into one tight coverage bitmap in a bold UI face, +/// sized so a digit stands `target` pixels tall. Characters are placed by +/// the font's own advances and side bearings, top-aligned — digits and +/// `%` share the cap height, so the row lines up. +fn rasterize_label(dc: HDC, label: &[u16], family: &[u16], target: u32) -> Option { + if label.is_empty() { + return None; + } + // SAFETY: `dc` is a live memory DC and the font created here is + // deselected and deleted on every exit path. + unsafe { + let mut font = create_glyph_font(family, target as i32, FW_BOLD as i32); + if font.is_null() { + return None; + } + let previous_font = SelectObject(dc, font); + let mut font_height = target as i32; + for _ in 0..3 { + let Some(metrics) = glyph_metrics(dc, u32::from(b'0')) else { + break; + }; + let size = metrics.gmBlackBoxY; + if size == 0 || size.abs_diff(target) <= 1 { + break; + } + let scaled = + (f64::from(font_height) * f64::from(target) / f64::from(size)).round() as i32; + if scaled == font_height { + break; + } + font_height = scaled; + let replacement = create_glyph_font(family, font_height, FW_BOLD as i32); + SelectObject(dc, replacement); + DeleteObject(font); + font = replacement; + if font.is_null() { + SelectObject(dc, previous_font); + return None; + } + } + + let mut rasters = Vec::with_capacity(label.len()); + for &character in label { + match glyph_metrics(dc, u32::from(character)) { + // `glyph_bitmap` writes the bitmap's real bounds back into + // the metrics, and the raster needs those. + Some(mut metrics) => match glyph_bitmap(dc, u32::from(character), &mut metrics) { + Some(coverage) => rasters.push(Raster { metrics, coverage }), + None => break, + }, + None => break, + } + } + SelectObject(dc, previous_font); + DeleteObject(font); + if rasters.len() != label.len() { + return None; + } + + let height = rasters.iter().map(Raster::height).max().unwrap_or(0); + let width: u32 = rasters + .iter() + .map(|r| r.metrics.gmCellIncX.max(0) as u32) + .sum(); + let mut coverage = vec![0u8; (width * height) as usize]; + let mut pen_x = 0u32; + for raster in &rasters { + let tight = raster.tight(); + let x = pen_x + u32::try_from(raster.metrics.gmptGlyphOrigin.x.max(0)).unwrap_or(0); + for row in 0..raster.height() as usize { + for column in 0..raster.width() as usize { + let level = tight[row * raster.width() as usize + column]; + if level == 0 { + continue; + } + let index = row * width as usize + x as usize + column; + if index < coverage.len() { + coverage[index] = coverage[index].max(level); + } + } + } + pen_x += raster.metrics.gmCellIncX.max(0) as u32; + } + Some(LabelRaster { + coverage, + width, + height, + }) + } +} + +/// The RGBA canvas the display is composed onto. +struct Canvas { + rgba: Vec, + width: u32, + height: u32, +} + +impl Canvas { + /// Stamps a tight coverage bitmap at (x0, y0), tinted with `ink`. + /// Coverage levels are normalized so the raster's densest pixel is + /// fully opaque (`GGO_GRAY8_BITMAP` does not promise a 255 peak). + fn blit(&mut self, x0: u32, y0: u32, coverage: &[u8], width: u32, ink: (u8, u8, u8)) { + let peak = coverage.iter().copied().max().unwrap_or(0); + if peak == 0 { + return; + } + let height = coverage.len() as u32 / width.max(1); + for row in 0..height as usize { + let y = y0 as usize + row; + if y as u32 >= self.height { + break; + } + for column in 0..width as usize { + let x = x0 as usize + column; + if x as u32 >= self.width { + break; + } + let level = coverage[row * width as usize + column]; + if level == 0 { + continue; + } + let alpha = ((u16::from(level) * 255) / u16::from(peak)).min(255) as u8; + let index = (y as u32 * self.width + x as u32) as usize * 4; + self.rgba[index] = ink.0; + self.rgba[index + 1] = ink.1; + self.rgba[index + 2] = ink.2; + self.rgba[index + 3] = alpha; + } + } + } +} + +/// Fits the font so the battery glyph's visible bounds span `target` +/// pixels wide — the right fit for a landscape glyph in a square icon (the +/// fit loop accepts within a pixel) — then rasterizes the glyph as an +/// 8-bit coverage bitmap. `None` when the font or glyph is missing. +fn fit_and_rasterize( + dc: HDC, + codepoint: u32, + family: &[u16], + weight: i32, + target: u32, +) -> Option { + // SAFETY: `dc` is a live memory DC and the font created here is + // deselected and deleted on every exit path. + unsafe { + let mut font = create_glyph_font(family, target as i32, weight); + if font.is_null() { + MISSING_GLYPH.with(|flag| flag.set(true)); + return None; + } + let previous_font = SelectObject(dc, font); + let mut font_height = target as i32; + let mut raster = None; + for _ in 0..4 { + let Some(mut metrics) = glyph_metrics(dc, codepoint) else { + MISSING_GLYPH.with(|flag| flag.set(true)); + break; + }; + let size = metrics.gmBlackBoxX; + if size == 0 { + break; + } + if size.abs_diff(target) <= 1 { + raster = glyph_bitmap(dc, codepoint, &mut metrics) + .map(|coverage| Raster { metrics, coverage }); + break; + } + let scaled = + (f64::from(font_height) * f64::from(target) / f64::from(size)).round() as i32; + if scaled == font_height { + raster = glyph_bitmap(dc, codepoint, &mut metrics) + .map(|coverage| Raster { metrics, coverage }); + break; + } + // Recreate the font at the scaled height and keep fitting. + font_height = scaled; + let replacement = create_glyph_font(family, font_height, weight); + SelectObject(dc, replacement); + DeleteObject(font); + font = replacement; + if font.is_null() { + MISSING_GLYPH.with(|flag| flag.set(true)); + break; + } + } + SelectObject(dc, previous_font); + DeleteObject(font); + raster + } +} + +/// Rasterizes the glyph as an 8-bit grayscale coverage bitmap +/// (`GGO_GRAY8_BITMAP`): GDI's own anti-aliased glyph raster, no drawing +/// surface involved. GDI writes the bitmap's *actual* bounds back into +/// `metrics` — which can come back a pixel wider than `GGO_METRICS` +/// reported (the anti-aliased edge column), so the caller must lay the +/// buffer out by the written-back values or every row skews. +fn glyph_bitmap(dc: HDC, codepoint: u32, metrics: &mut GLYPHMETRICS) -> Option> { + // SAFETY: `dc` is a live DC with a font selected; the metrics and + // matrix structs are valid for the call and the buffer is sized to hold + // the reported black box (rows are DWORD-aligned) plus slack — GDI + // rejects an exact-size buffer for some glyphs, asking for a few bytes + // more than stride × rows. + unsafe { + let stride = (metrics.gmBlackBoxX + 3) & !3; + let byte_count = stride as usize * metrics.gmBlackBoxY as usize; + for slack in [64usize, 1024, 8192] { + let mut buffer = vec![0u8; byte_count + slack]; + let mut matrix: MAT2 = std::mem::zeroed(); + matrix.eM11 = FIXED { fract: 0, value: 1 }; + matrix.eM22 = FIXED { fract: 0, value: 1 }; + let filled = GetGlyphOutlineW( + dc, + codepoint, + GGO_GRAY8_BITMAP, + metrics, + buffer.len() as u32, + buffer.as_mut_ptr().cast(), + &matrix, + ); + if filled != GDI_ERROR as u32 && filled as usize <= buffer.len() { + return Some(buffer); + } + } + None + } +} + +fn create_glyph_font(family: &[u16], height: i32, weight: i32) -> HFONT { + // SAFETY: a plain font creation; the face name is null-terminated and + // outlives the call. + unsafe { + CreateFontW( + -height, + 0, + 0, + 0, + weight, + 0, + 0, + 0, + u32::from(DEFAULT_CHARSET), + u32::from(OUT_TT_PRECIS), + u32::from(CLIP_DEFAULT_PRECIS), + u32::from(ANTIALIASED_QUALITY), + u32::from(DEFAULT_PITCH | FF_DONTCARE), + family.as_ptr(), + ) + } +} + +/// The glyph's measured visible bounds (`GGO_METRICS`): `None` when the +/// selected font cannot draw the character at all. +fn glyph_metrics(dc: HDC, codepoint: u32) -> Option { + // SAFETY: `dc` is a live DC with a font selected, and the metrics + // struct and transform matrix are valid for the call. + unsafe { + let mut matrix: MAT2 = std::mem::zeroed(); + matrix.eM11 = FIXED { fract: 0, value: 1 }; + matrix.eM22 = FIXED { fract: 0, value: 1 }; + let mut metrics: GLYPHMETRICS = std::mem::zeroed(); + let ok = GetGlyphOutlineW( + dc, + codepoint, + GGO_METRICS, + &mut metrics, + 0, + null_mut(), + &matrix, + ); + (ok != GDI_ERROR as u32).then_some(metrics) + } +} + pub fn autostart_enabled() -> bool { let subkey = wide(RUN_SUBKEY); let value_name = wide(VALUE_NAME); @@ -70,3 +592,123 @@ fn wide(value: impl AsRef) -> Vec { fn registry_error(code: WIN32_ERROR) -> std::io::Error { std::io::Error::from_raw_os_error(code as i32) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_glyph_matches_the_level_in_ten_percent_steps() { + assert_eq!(battery_glyph(0, false), 0xE850); + assert_eq!(battery_glyph(9, false), 0xE850); + assert_eq!(battery_glyph(10, false), 0xE851); + assert_eq!(battery_glyph(45, false), 0xE854); + assert_eq!(battery_glyph(95, false), 0xE859); + assert_eq!(battery_glyph(100, false), 0xE83F); + assert_eq!(battery_glyph(140, false), 0xE83F); + assert_eq!(battery_glyph(0, true), 0xE85A); + assert_eq!(battery_glyph(45, true), 0xE85E); + assert_eq!(battery_glyph(90, true), 0xE83E); + assert_eq!(battery_glyph(100, true), 0xE83F); + } + + #[test] + fn the_glyph_style_fills_the_square() { + for size in [16u32, 20, 24, 32] { + let (rgba, width, height) = battery_icon_rgba( + 87, + false, + super::super::TrayBatteryStyle::Glyph, + size, + (255, 255, 255), + ) + .expect("Segoe battery glyph should render"); + assert_eq!((width, height), (size, size), "square at {size}px"); + assert_eq!(rgba.len(), (size * size * 4) as usize); + let coverage = |x: u32, y: u32| rgba[((y * size + x) * 4 + 3) as usize]; + // The glyph's visible bounds fill the canvas: across the + // middle row, the covered span (first to last covered pixel — + // the battery's body-to-nub gap doesn't count against it) is + // at most two blank columns short of the full width. + let row = size / 2; + let covered: Vec = (0..size).filter(|x| coverage(*x, row) > 0).collect(); + let span = covered + .last() + .and_then(|last| covered.first().map(|first| last - first + 1)); + assert!( + span.is_some_and(|span| span + 2 >= size), + "glyph spans {span:?} of {size}px at {size}px" + ); + // The battery is a horizontal glyph, so it clears the top and + // bottom rows. + assert_eq!(coverage(size / 2, 0), 0); + assert_eq!(coverage(size / 2, size - 1), 0); + } + } + + #[test] + fn the_percentage_style_fills_the_square_with_the_number() { + for size in [16u32, 20, 24, 32] { + for percent in [87u8, 100] { + let (rgba, width, height) = battery_icon_rgba( + percent, + false, + super::super::TrayBatteryStyle::Percentage, + size, + (255, 255, 255), + ) + .expect("Segoe battery number should render"); + assert_eq!((width, height), (size, size), "square at {size}px"); + let coverage = |x: u32, y: u32| rgba[((y * size + x) * 4 + 3) as usize]; + let row = size / 2; + // The digits fill the square vertically and reach past + // half of the middle row ("100" is squeezed to fit its + // width inside). + let first = (0..width).find(|x| coverage(*x, row) > 0); + let last = (0..width).rev().find(|x| coverage(*x, row) > 0); + assert!( + first + .zip(last) + .is_some_and(|(first, last)| last + 1 - first >= size / 2), + "digits span {first:?}..{last:?} of {size}px at {size}px" + ); + // No content in the margin rows. + assert_eq!((0..size).filter(|x| coverage(*x, 0) > 0).count(), 0); + } + } + } + + #[test] + fn charging_renders_a_bolt_glyph_at_every_level() { + for percent in [0u8, 45, 90, 100] { + let (rgba, _, _) = battery_icon_rgba( + percent, + true, + super::super::TrayBatteryStyle::Glyph, + 16, + (255, 255, 255), + ) + .expect("charging display"); + assert!( + rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[3] > 0), + "charging at {percent}% must paint" + ); + } + } + + #[test] + fn the_ink_color_carries_through() { + for style in [ + super::super::TrayBatteryStyle::Glyph, + super::super::TrayBatteryStyle::Percentage, + ] { + let (rgba, _, _) = + battery_icon_rgba(12, false, style, 16, (250, 100, 50)).expect("display"); + for pixel in rgba.as_chunks::<4>().0 { + if pixel[3] > 0 { + assert_eq!((pixel[0], pixel[1], pixel[2]), (250, 100, 50)); + } + } + } + } +} diff --git a/src/service.rs b/src/service.rs index 479a0db..6d356c5 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::{HashMap, HashSet, VecDeque}, path::{Path, PathBuf}, sync::{ Arc, @@ -15,6 +15,7 @@ use tokio::sync::RwLock; use crate::{ applications::{self, ApplicationInfo}, config::{self, ApplicationProfile, BridgeConfig, GameConfig, LocalApplication}, + drivers, games::GameDetector, platform, }; @@ -98,13 +99,32 @@ const PROFILE_DEBOUNCE: Duration = Duration::from_millis(2500); /// without the control panel open. Each read spawns the native-hid helper. const BATTERY_POLL_INTERVAL: Duration = Duration::from_secs(5 * 60); -/// A reading older than this is shown as stale: more than two missed polls. +/// A reading this old is shown as stale: more than two missed polls. const BATTERY_STALE_AFTER: Duration = Duration::from_secs(12 * 60); /// A reading this old belongs to a mouse that is gone, not just asleep: it is /// dropped from the list instead of lingering as a ghost device. const BATTERY_EVICT_AFTER: Duration = Duration::from_secs(24 * 60 * 60); +/// How many samples of each device's percent history are kept for the tray +/// tooltip's time-remaining estimate. +const BATTERY_HISTORY_SAMPLES: usize = 48; + +/// Percent samples older than this no longer count toward a trend, so a +/// mouse's days-old readings never poison the estimate. +const BATTERY_TREND_WINDOW: Duration = Duration::from_secs(6 * 60 * 60); + +/// A trend needs at least this much history before an estimate is worth +/// showing: two samples 5 minutes apart are mostly quantization noise. +const BATTERY_MIN_TREND_SPAN: Duration = Duration::from_secs(30 * 60); + +/// The smallest drift (percent per minute) that counts as a trend, about 1% +/// per 3 hours — the pace a mouse battery actually moves at. +const BATTERY_TREND_EPSILON: f64 = 0.005; + +/// Estimates beyond this are noise for a mouse battery, not a plan. +const BATTERY_MAX_ESTIMATE_MINUTES: u32 = 48 * 60; + /// Drops battery entries that can no longer describe a live mouse: anything /// past `BATTERY_EVICT_AFTER`, and anything outside the saved profiles that /// has also gone stale (a renamed or removed device, or an id a client pushed @@ -440,6 +460,15 @@ pub struct BridgeService { native_automation: Arc>, } +/// A control panel is connected when it holds a HID WebSocket session or +/// handshook within the last few seconds. +fn panel_connected(state: &BridgeState) -> bool { + crate::hid::client_session_active() + || state + .last_client_heartbeat + .is_some_and(|heartbeat| heartbeat.elapsed() < Duration::from_secs(20)) +} + struct BridgeState { config: BridgeConfig, active_games: Vec, @@ -459,6 +488,28 @@ struct BridgeState { profile_seeded: bool, started_at: Instant, last_client_heartbeat: Option, + /// Who currently drives the mouse: Bridge in onboard memory mode or the + /// web panel in live mode. Tracked by the mode monitor. + device_mode: DeviceMode, +} + +/// Who currently drives the mouse. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeviceMode { + /// Bridge drives the mouse itself: the active profile's stored settings + /// live in the onboard memory and the battery is read natively. + Onboard, + /// The OpenMouse web panel holds the HID session and drives the mouse. + Live, +} + +impl DeviceMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Onboard => "onboard", + Self::Live => "live", + } + } } #[derive(Clone, Debug, Deserialize)] @@ -469,6 +520,15 @@ pub struct BatteryReading { pub percent: u8, #[serde(default)] pub charging: bool, + /// The settings currently in the mouse's onboard memory, when the + /// reader could see them — native reads report them; a control panel's + /// `PUT /v1/battery` may not. + #[serde(default)] + pub dpi: Option, + #[serde(default)] + pub polling_rate_hz: Option, + #[serde(default)] + pub switch_mode: Option, } struct BatteryState { @@ -476,7 +536,46 @@ struct BatteryState { device_name: String, percent: u8, charging: bool, + dpi: Option, + polling_rate_hz: Option, + switch_mode: Option, updated_at: Instant, + // Recent (reading time, percent) samples, oldest first, for the tray + // tooltip's time-remaining estimate. + history: VecDeque<(Instant, u8)>, +} + +/// Estimates the minutes left until the battery is empty (discharging) or +/// full (charging) from the percent history: the level's drift across the +/// samples inside the trend window. `None` while there is no confident +/// trend yet — too little history, or the level is holding still. +fn estimate_minutes_remaining( + history: &VecDeque<(Instant, u8)>, + percent: u8, + charging: bool, +) -> Option { + let now = Instant::now(); + let recent = |at: &Instant| now.duration_since(*at) <= BATTERY_TREND_WINDOW; + let (first_at, first_percent) = history.iter().find(|(at, _)| recent(at))?; + let (last_at, last_percent) = history.iter().rev().find(|(at, _)| recent(at))?; + if last_at <= first_at { + return None; + } + let span = last_at.duration_since(*first_at); + if span < BATTERY_MIN_TREND_SPAN { + return None; + } + let span_minutes = span.as_secs_f64() / 60.0; + let rate = f64::from(*last_percent as i16 - *first_percent as i16) / span_minutes; + let raw_minutes = if charging { + (rate > BATTERY_TREND_EPSILON).then(|| f64::from(100 - percent) / rate)? + } else { + (rate < -BATTERY_TREND_EPSILON).then(|| f64::from(percent) / -rate)? + }; + // Round to 5-minute granularity so the estimate holds still instead of + // jittering every snapshot. + let minutes = (raw_minutes / 5.0).round() as u32 * 5; + (minutes > 0 && minutes <= BATTERY_MAX_ESTIMATE_MINUTES).then_some(minutes) } #[derive(Clone, Debug, Serialize)] @@ -487,6 +586,18 @@ pub struct DeviceBattery { pub percent: u8, pub charging: bool, pub stale: bool, + /// Minutes until the battery is empty (discharging) or full (charging), + /// from the recent trend; `None` while there is no confident estimate. + #[serde(skip_serializing_if = "Option::is_none")] + pub minutes_remaining: Option, + /// The settings currently in the mouse's onboard memory, from the most + /// recent native status read; `None` when unreadable. + #[serde(skip_serializing_if = "Option::is_none")] + pub dpi: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub polling_rate_hz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub switch_mode: Option, } /// A mouse the control panel currently holds open over native HID, most @@ -521,6 +632,12 @@ pub struct BridgeSnapshot { pub games: Vec, pub tracked_game_count: usize, pub battery_threshold_percent: u8, + pub tray_battery_style: String, + pub hybrid_mode: bool, + /// Who drives the mouse right now: `"onboard"` (Bridge applies the + /// stored settings and reads the battery) or `"live"` (the web panel + /// holds the HID session). + pub device_mode: String, pub autostart_enabled: bool, pub automatic_updates: bool, pub notification_sound: bool, @@ -556,6 +673,7 @@ impl BridgeService { profile_seeded: false, started_at: Instant::now(), last_client_heartbeat: None, + device_mode: DeviceMode::Onboard, })), config_path: Arc::new(config_path), notice_listener: Arc::default(), @@ -629,10 +747,8 @@ impl BridgeService { pub async fn snapshot(&self) -> BridgeSnapshot { let state = self.inner.read().await; - let client_connected = crate::hid::client_session_active() - || state - .last_client_heartbeat - .is_some_and(|heartbeat| heartbeat.elapsed() < Duration::from_secs(20)); + let client_connected = panel_connected(&state); + let device_mode = state.device_mode; let foreground_application = state .applications .iter() @@ -652,12 +768,23 @@ impl BridgeService { .battery .iter() .filter(|(_, entry)| entry.updated_at.elapsed() < BATTERY_EVICT_AFTER) - .map(|(device_id, entry)| DeviceBattery { - device_id: device_id.clone(), - device_name: entry.device_name.clone(), - percent: entry.percent, - charging: entry.charging, - stale: entry.updated_at.elapsed() >= BATTERY_STALE_AFTER, + .map(|(device_id, entry)| { + let stale = entry.updated_at.elapsed() >= BATTERY_STALE_AFTER; + DeviceBattery { + device_id: device_id.clone(), + device_name: entry.device_name.clone(), + percent: entry.percent, + charging: entry.charging, + minutes_remaining: if stale { + None + } else { + estimate_minutes_remaining(&entry.history, entry.percent, entry.charging) + }, + dpi: entry.dpi, + polling_rate_hz: entry.polling_rate_hz, + switch_mode: entry.switch_mode.clone(), + stale, + } }) .collect::>(); batteries.sort_by(|a, b| a.device_id.cmp(&b.device_id)); @@ -719,6 +846,9 @@ impl BridgeService { .filter(|target| target.kind == config::LocalApplicationKind::Game) .count(), battery_threshold_percent: state.config.battery_threshold_percent, + tray_battery_style: state.config.tray_battery_style.clone(), + hybrid_mode: state.config.hybrid_mode, + device_mode: device_mode.as_str().to_owned(), autostart_enabled: platform::autostart_enabled(), automatic_updates: state.config.automatic_updates, notification_sound: state.config.notification_sound, @@ -932,6 +1062,25 @@ impl BridgeService { .await } + pub async fn set_tray_battery_style(&self, style: &str) -> Result<()> { + let config = { + let mut state = self.inner.write().await; + state.config.tray_battery_style = style.to_owned(); + state.config = state.config.clone().normalized(); + state.config.clone() + }; + config::save(&self.config_path, &config) + } + + pub async fn set_hybrid_mode(&self, enabled: bool) -> Result<()> { + let config = { + let mut state = self.inner.write().await; + state.config.hybrid_mode = enabled; + state.config.clone() + }; + config::save(&self.config_path, &config) + } + pub async fn set_automatic_updates(&self, enabled: bool) -> Result<()> { self.update_config(|config| { config.automatic_updates = enabled; @@ -970,13 +1119,24 @@ impl BridgeService { let mut state = self.inner.write().await; let threshold = state.config.battery_threshold_percent; let cooldown = Duration::from_secs(state.config.alert_cooldown_minutes * 60); - let previous_alert = state - .battery - .get(&reading.device_id) - .and_then(|entry| entry.last_alert); + let previous = state.battery.get(&reading.device_id); + let previous_alert = previous.and_then(|entry| entry.last_alert); let should_alert = !reading.charging && reading.percent <= threshold && previous_alert.is_none_or(|last| last.elapsed() >= cooldown); + let mut history = previous + .map(|entry| entry.history.clone()) + .unwrap_or_default(); + history.push_back((Instant::now(), reading.percent)); + while history.len() > BATTERY_HISTORY_SAMPLES { + history.pop_front(); + } + // A control panel's `PUT /v1/battery` reports the charge level + // only; keep the settings a previous native status read saw + // instead of blanking them until the next native poll. + let (previous_dpi, previous_rate, previous_mode) = previous + .map(|entry| (entry.dpi, entry.polling_rate_hz, entry.switch_mode.clone())) + .unwrap_or((None, None, None)); state.battery.insert( reading.device_id.clone(), BatteryState { @@ -988,7 +1148,11 @@ impl BridgeService { device_name: reading.device_name.clone(), percent: reading.percent, charging: reading.charging, + dpi: reading.dpi.or(previous_dpi), + polling_rate_hz: reading.polling_rate_hz.or(previous_rate), + switch_mode: reading.switch_mode.or(previous_mode), updated_at: Instant::now(), + history, }, ); should_alert @@ -1016,39 +1180,52 @@ impl BridgeService { interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; - let devices = profile_devices(&service.inner.read().await.config); - // Pruning is in-memory only, so it runs even while the - // control panel holds a HID session and reads are paused. - prune_batteries(&mut service.inner.write().await.battery, &devices); - if crate::hid::client_session_active() { + poll_batteries_once(service.clone()).await; + } + }); + } + + /// Watches the connection state and flips between the two halves of + /// hybrid mode: while the OpenMouse web panel is disconnected, Bridge + /// drives the mouse in onboard memory mode (stored settings applied, + /// battery read natively); the moment the panel connects it takes the + /// mouse over live, and when it leaves, Bridge restores the stored + /// settings and reads the battery right away. + pub fn start_mode_monitor(&self) { + let service = self.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(1)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + let mode = { + let state = service.inner.read().await; + if panel_connected(&state) { + DeviceMode::Live + } else { + DeviceMode::Onboard + } + }; + let previous = { + let mut state = service.inner.write().await; + let previous = state.device_mode; + state.device_mode = mode; + previous + }; + if previous == mode { continue; } - for (device_id, device_name) in devices { - let id = device_id.clone(); - let outcome = - tokio::task::spawn_blocking(move || crate::drivers::read_battery(&id)) - .await; - match outcome { - Ok(Ok(Some(battery))) => { - let reading = BatteryReading { - device_id, - device_name, - percent: battery.percent, - charging: battery.charging, - }; - if let Err(error) = service.record_battery(reading).await { - tracing::warn!(%error, "Could not record the mouse battery"); - } - } - Ok(Ok(None)) => { - tracing::debug!(%device_id, "No native battery reader for this device"); - } - Ok(Err(error)) => { - tracing::debug!(%error, %device_id, "Could not read the mouse battery"); - } - Err(error) => { - tracing::warn!(%error, "The battery reader stopped unexpectedly"); + match mode { + DeviceMode::Live => { + tracing::info!("the web panel connected — it drives the mouse live now"); + } + DeviceMode::Onboard => { + tracing::info!("the web panel disconnected — resuming onboard memory mode"); + let hybrid = service.inner.read().await.config.hybrid_mode; + if !hybrid { + continue; } + resume_onboard(&service).await; } } } @@ -1241,6 +1418,158 @@ impl BridgeService { } } +/// One battery-polling pass: covers every device named by the saved profiles +/// plus every mouse Bridge discovered on the HID bus whose brand no profile +/// already names, reads each mouse's battery natively (skipped while the web +/// panel holds the session — it reports the battery itself over +/// `PUT /v1/battery`) and records it. Used by the five-minute monitor and by +/// onboard-mode handovers. +async fn poll_batteries_once(service: BridgeService) { + let profiled = profile_devices(&service.inner.read().await.config); + let discovered = tokio::task::spawn_blocking(drivers::detect_mice) + .await + .unwrap_or_default(); + let devices = merge_detected_mice(profiled, &discovered); + // Pruning is in-memory only, so it runs even while the + // control panel holds a HID session and reads are paused. + prune_batteries(&mut service.inner.write().await.battery, &devices); + if crate::hid::client_session_active() { + return; + } + for (device_id, device_name) in devices { + let id = device_id.clone(); + let outcome = tokio::task::spawn_blocking(move || crate::drivers::read_status(&id)).await; + match outcome { + Ok(Ok(Some(status))) => { + // The web panel reports batteries as `:` (the + // driver's own model name), while discovery ids use the HID + // descriptor's tail (a LIGHTSPEED receiver reads + // `Logitech:046d:c547`). Recording under the panel's name + // for the same physical mouse keeps one entry instead of + // duplicate rows; the discovery-shaped entry is dropped. + let record_id = status + .battery + .reported_name + .as_ref() + .map(|name| canonical_battery_identity(&device_id, name)) + .unwrap_or_else(|| device_id.clone()); + if record_id != device_id { + service.inner.write().await.battery.remove(&device_id); + } + let record_name = status.battery.reported_name.clone().unwrap_or(device_name); + let reading = BatteryReading { + device_id: record_id, + device_name: record_name, + percent: status.battery.percent, + charging: status.battery.charging, + dpi: status.dpi, + polling_rate_hz: status.polling_rate_hz, + switch_mode: status.switch_mode, + }; + if let Err(error) = service.record_battery(reading).await { + tracing::warn!(%error, "Could not record the mouse battery"); + } + } + Ok(Ok(None)) => { + tracing::debug!(%device_id, "No native battery reader for this device"); + } + Ok(Err(error)) => { + tracing::debug!(%error, %device_id, "Could not read the mouse battery"); + } + Err(error) => { + tracing::warn!(%error, "The battery reader stopped unexpectedly"); + } + } + } +} + +/// The device id the web panel would use for the same physical mouse: +/// `:`, from the driver's own `MouseStatus.name`. +fn canonical_battery_identity(device_id: &str, reported_name: &str) -> String { + let brand = device_id.split(':').next().unwrap_or_default(); + format!("{brand}:{reported_name}") +} + +/// The devices battery polling should cover: the saved profiles' devices plus +/// discovered mice whose brand no profile already names — discovery fills the +/// gap before the panel has ever saved a profile, without double-reporting a +/// mouse a profile already covers (profile and discovery ids for the same +/// physical mouse differ, so brand-level dedup keeps one battery entry). +fn merge_detected_mice( + mut profiled: Vec<(String, String)>, + detected: &[drivers::DetectedMouse], +) -> Vec<(String, String)> { + for mouse in detected { + let brand = mouse.device_id.split(':').next().unwrap_or_default(); + let already_covered = profiled.iter().any(|(id, _)| { + id.split(':') + .next() + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(brand)) + }); + if !already_covered { + profiled.push((mouse.device_id.clone(), mouse.name.clone())); + } + } + profiled +} + +/// Enters onboard memory mode: pushes the stored settings to the mouse's +/// onboard memory (when a default profile exists — without one, the device +/// simply keeps the memory it already has) and reads the battery right away. +/// Shared by the startup seed and the panel-disconnect handover. +async fn resume_onboard(service: &BridgeService) { + let profile = { + let state = service.inner.read().await; + resolve_profile_retaining( + &state.config, + &state.applications, + &state.active_games, + &state.processes, + None, + ) + }; + if let Some(profile) = profile { + tracing::info!( + profile = %profile.application.name, + "applying the stored settings to the onboard memory" + ); + std::thread::spawn(move || { + apply_natively(&profile); + }); + } + tokio::spawn(poll_batteries_once(service.clone())); +} + +fn apply_natively(profile: &ApplicationProfile) -> ApplyOutcome { + match crate::drivers::apply_profile(profile) { + Ok(true) => { + tracing::info!( + profile = %profile.application.name, + device = %profile.device.name, + "Applied the mouse profile natively" + ); + ApplyOutcome::Applied + } + Ok(false) => { + tracing::debug!( + profile = %profile.application.name, + device = %profile.device.name, + "No native driver for this device; not applying natively" + ); + ApplyOutcome::NoDriver + } + Err(error) => { + tracing::warn!( + %error, + profile = %profile.application.name, + device = %profile.device.name, + "Could not apply the mouse profile natively" + ); + ApplyOutcome::Failed + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1986,6 +2315,9 @@ mod tests { device_name: "Mouse".into(), percent: 90, charging: false, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) .await .unwrap() @@ -1997,22 +2329,115 @@ mod tests { device_name: "Mouse".into(), percent: 10, charging: true, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) .await .unwrap() ); } + #[tokio::test] + async fn settings_less_panel_reports_keep_the_stored_settings() { + let bridge = service(BridgeConfig::default()); + // Native status read: battery plus onboard settings. + bridge + .record_battery(BatteryReading { + device_id: "Logitech:PRO X SUPERLIGHT 2 SE".into(), + device_name: "PRO X SUPERLIGHT 2 SE".into(), + percent: 81, + charging: false, + dpi: Some(800), + polling_rate_hz: Some(1000), + switch_mode: Some("Hybrid".into()), + }) + .await + .unwrap(); + // The panel's PUT carries the charge level only. + bridge + .record_battery(BatteryReading { + device_id: "Logitech:PRO X SUPERLIGHT 2 SE".into(), + device_name: "PRO X SUPERLIGHT 2 SE".into(), + percent: 80, + charging: false, + dpi: None, + polling_rate_hz: None, + switch_mode: None, + }) + .await + .unwrap(); + let battery = bridge + .snapshot() + .await + .batteries + .into_iter() + .next() + .expect("battery entry survives"); + assert_eq!(battery.percent, 80); + assert_eq!(battery.dpi, Some(800)); + assert_eq!(battery.polling_rate_hz, Some(1000)); + assert_eq!(battery.switch_mode.as_deref(), Some("Hybrid")); + } + fn battery_entry(age: Duration) -> BatteryState { BatteryState { last_alert: None, device_name: "Mouse".into(), percent: 50, charging: false, + dpi: None, + polling_rate_hz: None, + switch_mode: None, updated_at: Instant::now() - age, + history: VecDeque::new(), } } + fn history(minutes_ago_percent: &[(u64, u8)]) -> VecDeque<(Instant, u8)> { + let now = Instant::now(); + minutes_ago_percent + .iter() + .map(|(minutes_ago, percent)| (now - Duration::from_secs(minutes_ago * 60), *percent)) + .collect() + } + + #[test] + fn a_steady_drain_estimates_time_to_empty() { + let samples = history(&[(60, 50), (30, 48), (0, 45)]); + assert_eq!(estimate_minutes_remaining(&samples, 45, false), Some(540)); + } + + #[test] + fn a_steady_charge_estimates_time_to_full() { + let samples = history(&[(60, 20), (0, 80)]); + assert_eq!(estimate_minutes_remaining(&samples, 80, true), Some(20)); + } + + #[test] + fn a_flat_level_has_no_estimate() { + let samples = history(&[(60, 50), (0, 50)]); + assert_eq!(estimate_minutes_remaining(&samples, 50, false), None); + } + + #[test] + fn too_little_history_has_no_estimate() { + let samples = history(&[(10, 50), (0, 45)]); + assert_eq!(estimate_minutes_remaining(&samples, 45, false), None); + } + + #[test] + fn samples_outside_the_trend_window_are_ignored() { + let samples = history(&[(10 * 60, 50), (0, 45)]); + assert_eq!(estimate_minutes_remaining(&samples, 45, false), None); + } + + #[test] + fn an_estimate_past_two_days_is_noise() { + let samples = history(&[(60, 50), (0, 49)]); + assert_eq!(estimate_minutes_remaining(&samples, 49, false), None); + } + #[test] fn prune_evicts_old_entries_and_unknown_stale_ones() { let mut battery = HashMap::new(); @@ -2051,6 +2476,9 @@ mod tests { device_name: id.into(), percent: 80, charging: false, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) .await .unwrap(); @@ -2149,4 +2577,78 @@ mod tests { assert!(bridge.snapshot().await.client_connected); } + + #[tokio::test] + async fn the_mode_flips_to_live_when_the_panel_handshakes() { + let bridge = service(BridgeConfig::default()); + assert_eq!(bridge.snapshot().await.device_mode, "onboard"); + + let monitored = bridge.clone(); + monitored.start_mode_monitor(); + bridge.record_client_heartbeat().await; + // The mode monitor ticks every second; give it a beat to spot the + // handshake and flip the mode. + tokio::time::sleep(Duration::from_millis(1400)).await; + + assert_eq!(bridge.snapshot().await.device_mode, "live"); + assert!(bridge.snapshot().await.hybrid_mode); + } + + #[test] + fn the_discovered_battery_id_becomes_the_panel_style_id() { + // The panel reports `:`; discovery's descriptor tail + // (a LIGHTSPEED receiver) must canonicalize onto that id so the + // same mouse never appears as two rows. + assert_eq!( + canonical_battery_identity("Logitech:046d:c547", "PRO X SUPERLIGHT 2 SE"), + "Logitech:PRO X SUPERLIGHT 2 SE" + ); + // Brands with no model name keep their discovered id. + assert_eq!( + canonical_battery_identity("Logitech:046d:c547", ""), + "Logitech:" + ); + } + + #[test] + fn discovered_mice_fill_the_gap_without_shadowing_profiled_devices() { + let detected = |device_id: &str, name: &str| drivers::DetectedMouse { + device_id: device_id.into(), + name: name.into(), + }; + // No profiles: every discovered mouse is polled. + assert_eq!( + merge_detected_mice( + Vec::new(), + &[detected("Pulsar:0d1f0200", "Pulsar X2 CrazyLight")] + ), + vec![( + "Pulsar:0d1f0200".to_owned(), + "Pulsar X2 CrazyLight".to_owned() + )] + ); + // A profile already names the brand: discovery adds nothing, so the + // same physical mouse is never reported under two ids. + assert_eq!( + merge_detected_mice( + vec![("Pulsar:X2 CrazyLight".to_owned(), "X2".to_owned())], + &[detected("Pulsar:0d1f0200", "Pulsar X2 CrazyLight")] + ), + vec![("Pulsar:X2 CrazyLight".to_owned(), "X2".to_owned())] + ); + // Brand matching is case-insensitive; other brands still merge. + assert_eq!( + merge_detected_mice( + vec![("pulsar:X2".to_owned(), "X2".to_owned())], + &[ + detected("Pulsar:0d1f0200", "Pulsar X2 CrazyLight"), + detected("Delux:M800", "Delux M800") + ] + ), + vec![ + ("pulsar:X2".to_owned(), "X2".to_owned()), + ("Delux:M800".to_owned(), "Delux M800".to_owned()) + ] + ); + } }