diff --git a/client/Cargo.toml b/client/Cargo.toml index ce811a3..307b1fc 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.19" +version = "0.11.20" edition = "2024" [dependencies] diff --git a/client/src/launcher/power.rs b/client/src/launcher/power.rs index 832b86d..faaa6f6 100644 --- a/client/src/launcher/power.rs +++ b/client/src/launcher/power.rs @@ -65,5 +65,6 @@ fn map_state(reply: lesavka_common::lesavka::CapturePowerState) -> CapturePowerS detail: reply.detail, active_leases: reply.active_leases, mode: reply.mode, + detected_devices: reply.detected_devices, } } diff --git a/client/src/launcher/preview.rs b/client/src/launcher/preview.rs index 21e5d41..0471768 100644 --- a/client/src/launcher/preview.rs +++ b/client/src/launcher/preview.rs @@ -1763,6 +1763,7 @@ mod tests { detail: "active/running".to_string(), active_leases: 1, mode: "auto".to_string(), + detected_devices: 2, })) } diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index 5b37509..2b38dde 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -272,6 +272,7 @@ pub struct CapturePowerStatus { pub detail: String, pub active_leases: u32, pub mode: String, + pub detected_devices: u32, } impl Default for CapturePowerStatus { @@ -283,6 +284,7 @@ impl Default for CapturePowerStatus { detail: "unknown".to_string(), active_leases: 0, mode: "auto".to_string(), + detected_devices: 0, } } } @@ -1131,6 +1133,7 @@ mod tests { detail: "active/running".to_string(), active_leases: 2, mode: "forced-on".to_string(), + detected_devices: 2, }); assert!(state.capture_power.available); diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 058b5bf..26b5b43 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -198,6 +198,7 @@ fn unavailable_capture_power(detail: String) -> CapturePowerStatus { detail, active_leases: 0, mode: "auto".to_string(), + detected_devices: 0, } } @@ -2076,6 +2077,7 @@ mod tests { detail: "inactive/dead".to_string(), active_leases: 1, mode: "forced-off".to_string(), + detected_devices: 0, }); assert!(!session_preview_active(&state, true)); diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index a6a5993..07482d5 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -156,10 +156,6 @@ pub fn build_launcher_view( build_status_chip_with_light("Inputs", "Local"); let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown"); let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause"); - stabilize_chip(&relay_chip, 102); - stabilize_chip(&routing_chip, 84); - stabilize_chip(&gpio_chip, 84); - stabilize_chip(&shortcut_chip, 88); chips.append(&relay_chip); chips.append(&routing_chip); chips.append(&gpio_chip); @@ -721,7 +717,7 @@ pub fn install_css(window: >k::ApplicationWindow) { background: rgba(91, 179, 162, 0.12); border: 1px solid rgba(91, 179, 162, 0.25); border-radius: 999px; - padding: 7px 10px; + padding: 6px 9px; } box.status-light { min-width: 10px; @@ -735,11 +731,18 @@ pub fn install_css(window: >k::ApplicationWindow) { box.status-light-idle { background: rgba(214, 81, 81, 0.92); } + box.status-light-warning { + background: rgba(242, 143, 54, 0.95); + } + box.status-light-caution { + background: rgba(227, 201, 73, 0.95); + } label.status-chip-label { font-size: 0.78rem; opacity: 0.72; } label.status-chip-value { + font-size: 0.93rem; font-weight: 700; } box.display-card { @@ -837,6 +840,7 @@ fn build_subgroup(title: &str) -> gtk::Box { fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); chip.add_css_class("status-chip"); + chip.set_hexpand(false); let label_widget = gtk::Label::new(Some(label)); label_widget.add_css_class("status-chip-label"); @@ -852,6 +856,7 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box, gtk::Label) { let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); chip.add_css_class("status-chip"); + chip.set_hexpand(false); let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6); meta.add_css_class("status-chip-meta"); @@ -871,10 +876,6 @@ fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box (chip, light, value_widget) } -fn stabilize_chip(chip: >k::Box, width: i32) { - chip.set_size_request(width, -1); -} - pub fn sync_feed_source_combo( combo: >k::ComboBoxText, options: Vec, diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index 63f2ca9..b3b2d43 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -29,7 +29,10 @@ pub type RelayChild = Child; pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { let relay_live = child_running || state.remote_active; - set_status_light(&widgets.summary.relay_light, state.server_available); + set_status_light( + &widgets.summary.relay_light, + StatusLightState::from_active(state.server_available), + ); widgets.summary.relay_value.set_text( state .server_version @@ -48,7 +51,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi ); set_status_light( &widgets.summary.routing_light, - matches!(state.routing, InputRouting::Remote), + StatusLightState::from_active(matches!(state.routing, InputRouting::Remote)), ); widgets .summary @@ -56,7 +59,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi .set_text(&capitalize(routing_name(state.routing))); set_status_light( &widgets.summary.gpio_light, - state.capture_power.available && state.capture_power.enabled, + gpio_light_state(&state.capture_power), ); widgets .summary @@ -455,10 +458,13 @@ pub fn gpio_power_label(power: &CapturePowerStatus) -> String { if !power.available { return "Unavailable".to_string(); } - if power.enabled { - "Power On".to_string() - } else { - "Power Off".to_string() + if !power.enabled { + return "Power Off".to_string(); + } + match power.detected_devices { + 0 => "No Eyes".to_string(), + 1 => "1 Eye".to_string(), + count => format!("{count} Eyes"), } } @@ -466,22 +472,69 @@ pub fn capture_power_detail(power: &CapturePowerStatus) -> String { if !power.available { return format!("{} is unavailable: {}", power.unit, power.detail); } + let detected = if power.enabled { + format!(" • {}", gpio_detection_detail(power.detected_devices)) + } else { + String::new() + }; match power.mode.as_str() { "forced-on" => format!( - "{} • awake • {} • leases {}", - power.unit, power.detail, power.active_leases + "{} • awake • {}{} • leases {}", + power.unit, power.detail, detected, power.active_leases ), "forced-off" => format!( - "{} • dark • {} • leases {}", - power.unit, power.detail, power.active_leases + "{} • dark • {}{} • leases {}", + power.unit, power.detail, detected, power.active_leases ), _ => format!( - "{} • auto • {} • leases {}", - power.unit, power.detail, power.active_leases + "{} • auto • {}{} • leases {}", + power.unit, power.detail, detected, power.active_leases ), } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StatusLightState { + Idle, + Live, + Warning, + Caution, +} + +impl StatusLightState { + fn from_active(active: bool) -> Self { + if active { Self::Live } else { Self::Idle } + } + + fn css_class(self) -> &'static str { + match self { + Self::Idle => "status-light-idle", + Self::Live => "status-light-live", + Self::Warning => "status-light-warning", + Self::Caution => "status-light-caution", + } + } +} + +fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState { + if !power.available || !power.enabled { + return StatusLightState::Idle; + } + match power.detected_devices { + 0 => StatusLightState::Warning, + 1 => StatusLightState::Caution, + _ => StatusLightState::Live, + } +} + +fn gpio_detection_detail(detected_devices: u32) -> String { + match detected_devices { + 0 => "no eyes detected".to_string(), + 1 => "1 eye detected".to_string(), + count => format!("{count} eyes detected"), + } +} + /// Highlights the currently active capture mode so it reads like a segmented control. fn sync_power_mode_button_styles(widgets: &LauncherWidgets, mode: &str) { for button in [ @@ -1070,14 +1123,12 @@ pub fn next_input_routing(routing: InputRouting) -> InputRouting { } } -fn set_status_light(light: >k::Box, active: bool) { +fn set_status_light(light: >k::Box, state: StatusLightState) { light.remove_css_class("status-light-live"); light.remove_css_class("status-light-idle"); - light.add_css_class(if active { - "status-light-live" - } else { - "status-light-idle" - }); + light.remove_css_class("status-light-warning"); + light.remove_css_class("status-light-caution"); + light.add_css_class(state.css_class()); } fn classify_log_tags(message: &str) -> Vec<&'static str> { @@ -1163,6 +1214,35 @@ mod tests { assert!(running.contains("mic monitor")); } + #[test] + fn gpio_power_label_tracks_detected_devices() { + let mut power = CapturePowerStatus::default(); + assert_eq!(gpio_power_label(&power), "Unavailable"); + + power.available = true; + assert_eq!(gpio_power_label(&power), "Power Off"); + + power.enabled = true; + assert_eq!(gpio_power_label(&power), "No Eyes"); + + power.detected_devices = 1; + assert_eq!(gpio_power_label(&power), "1 Eye"); + + power.detected_devices = 2; + assert_eq!(gpio_power_label(&power), "2 Eyes"); + } + + #[test] + fn capture_power_detail_mentions_detected_eyes_when_powered() { + let mut power = CapturePowerStatus::default(); + power.available = true; + power.enabled = true; + power.detail = "active/running".to_string(); + power.detected_devices = 1; + + assert!(capture_power_detail(&power).contains("1 eye detected")); + } + #[test] fn compact_device_name_prefers_basename_when_available() { assert_eq!(compact_device_name("/dev/video0"), "video0"); diff --git a/common/Cargo.toml b/common/Cargo.toml index a3ac148..41d5acd 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.19" +version = "0.11.20" edition = "2024" build = "build.rs" diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index 666685d..a12e33c 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -44,6 +44,7 @@ message CapturePowerState { string detail = 4; uint32 active_leases = 5; string mode = 6; + uint32 detected_devices = 7; } enum CapturePowerCommand { CAPTURE_POWER_COMMAND_UNSPECIFIED = 0; diff --git a/common/src/cli.rs b/common/src/cli.rs index adafe8e..f01b289 100644 --- a/common/src/cli.rs +++ b/common/src/cli.rs @@ -17,6 +17,6 @@ mod tests { #[test] fn banner_includes_version() { - assert_eq!(banner("0.11.19"), "lesavka-common CLI (v0.11.19)"); + assert_eq!(banner("0.11.20"), "lesavka-common CLI (v0.11.20)"); } } diff --git a/server/Cargo.toml b/server/Cargo.toml index c630b65..98908c7 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.19" +version = "0.11.20" edition = "2024" autobins = false diff --git a/server/src/capture_power.rs b/server/src/capture_power.rs index c1b2a8d..749b592 100644 --- a/server/src/capture_power.rs +++ b/server/src/capture_power.rs @@ -186,6 +186,7 @@ impl CapturePowerManager { Some(false) => "forced-off".to_string(), None => "auto".to_string(), }, + detected_devices: 0, }) } @@ -442,6 +443,7 @@ impl CapturePowerManager { } else { "forced-off".to_string() }, + detected_devices: 0, }) } @@ -453,6 +455,7 @@ impl CapturePowerManager { detail: "inactive/dead".to_string(), active_leases: 0, mode: "auto".to_string(), + detected_devices: 0, }) } @@ -464,6 +467,7 @@ impl CapturePowerManager { detail: "inactive/dead".to_string(), active_leases: 0, mode: "auto".to_string(), + detected_devices: 0, }) } } diff --git a/server/src/main.rs b/server/src/main.rs index bc61d18..8b73a58 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -4,6 +4,7 @@ #[forbid(unsafe_code)] use futures_util::{Stream, StreamExt}; use std::collections::HashMap; +use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc, time::Duration}; use tokio::sync::{Mutex, broadcast}; @@ -111,6 +112,18 @@ impl Handler { Ok(()) } + fn detected_capture_devices() -> u32 { + ["/dev/lesavka_l_eye", "/dev/lesavka_r_eye"] + .into_iter() + .filter(|path| Path::new(path).exists()) + .count() as u32 + } + + fn with_detected_capture_devices(mut state: CapturePowerState) -> CapturePowerState { + state.detected_devices = Self::detected_capture_devices(); + state + } + async fn capture_video_reply( &self, req: MonitorRequest, @@ -285,6 +298,7 @@ impl Handler { self.capture_power .snapshot() .await + .map(Self::with_detected_capture_devices) .map(Response::new) .map_err(|e| Status::internal(format!("{e:#}"))) } @@ -303,6 +317,7 @@ impl Handler { CapturePowerCommand::Unspecified => self.capture_power.set_manual(req.enabled).await, }; result + .map(Self::with_detected_capture_devices) .map(Response::new) .map_err(|e| Status::internal(format!("{e:#}"))) }