diff --git a/src/dashboard.rs b/src/dashboard.rs index e358ab4..29fb8e8 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -9,8 +9,7 @@ use std::sync::atomic::AtomicBool; use std::sync::mpsc::Receiver; use std::time::Duration; -use crate::bluez::{FAIR_RSSI, GOOD_RSSI, STRONG_RSSI}; -use crate::device_status::{HeadsetStatus, StatusUpdate, start_polling}; +use crate::device_status::{HeadsetStatus, StatusPresentation, StatusUpdate, start_polling}; use crate::router::Headset; use crate::routing_status::RoutingStatus; use crate::transmit::Mode; @@ -85,42 +84,21 @@ impl Dashboard { )?; for address in allowed { let bluetooth = self.state.get(address); - let status = |flag: Option| match flag { - Some(true) => "yes", - Some(false) => "no", - None => "?", - }; let headset = headsets.get(address); + let presentation = + StatusPresentation::new(bluetooth, headset.map(Headset::has_duplex_audio)); let (tx, rx) = routes.counts(address); - let signal = bluetooth - .filter(|info| info.connected == Some(true)) - .and_then(|info| info.rssi) - .map_or_else( - || "unknown".to_string(), - |rssi| { - let quality = if rssi >= STRONG_RSSI { - "strong" - } else if rssi >= GOOD_RSSI { - "good" - } else if rssi >= FAIR_RSSI { - "fair" - } else { - "weak" - }; - format!("{rssi} dBm ({quality})") - }, - ); + let signal = presentation.dashboard_signal(); writeln!( output, "{:<18} {address} {:<9} {:<6} {tx:>2}/{rx:<2} {signal}", - bluetooth - .and_then(|info| info.name.as_deref()) - .unwrap_or("—") + presentation + .name_or("—") .chars() .take(18) .collect::(), - status(bluetooth.and_then(|info| info.connected)), - status(headset.map(Headset::has_duplex_audio)) + presentation.connection.dashboard_label(), + presentation.dashboard_duplex_label() )?; } writeln!( diff --git a/src/device_status.rs b/src/device_status.rs index a026e7e..ef7c608 100644 --- a/src/device_status.rs +++ b/src/device_status.rs @@ -8,7 +8,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver}; use std::time::Duration; -use crate::bluez::{bluetooth_name, device_flag, signal_strength}; +use crate::bluez::{ + FAIR_RSSI, GOOD_RSSI, STRONG_RSSI, bluetooth_name, device_flag, signal_strength, +}; use crate::process::{COMMAND_TIMEOUT, command_cancellable}; use crate::router::{Headset, Router}; use crate::worker::Worker; @@ -32,6 +34,123 @@ impl HeadsetStatus { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConnectionState { + Connected, + Disconnected, + Unknown, +} + +impl ConnectionState { + pub(crate) fn from_flag(connected: Option) -> Self { + match connected { + Some(true) => Self::Connected, + Some(false) => Self::Disconnected, + None => Self::Unknown, + } + } + + pub(crate) fn dashboard_label(self) -> &'static str { + match self { + Self::Connected => "yes", + Self::Disconnected => "no", + Self::Unknown => "?", + } + } + + pub(crate) fn panel_label(self) -> &'static str { + match self { + Self::Connected => "connected", + Self::Disconnected => "disconnected", + Self::Unknown => "unknown", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SignalQuality { + Strong, + Good, + Fair, + Weak, + Unknown, +} + +impl SignalQuality { + pub(crate) fn from_rssi(rssi: Option) -> Self { + match rssi { + Some(rssi) if rssi >= STRONG_RSSI => Self::Strong, + Some(rssi) if rssi >= GOOD_RSSI => Self::Good, + Some(rssi) if rssi >= FAIR_RSSI => Self::Fair, + Some(_) => Self::Weak, + None => Self::Unknown, + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::Strong => "strong", + Self::Good => "good", + Self::Fair => "fair", + Self::Weak => "weak", + Self::Unknown => "unknown", + } + } +} + +pub(crate) struct StatusPresentation<'a> { + pub(crate) name: Option<&'a str>, + pub(crate) connection: ConnectionState, + pub(crate) duplex: Option, + pub(crate) rssi: Option, +} + +impl<'a> StatusPresentation<'a> { + pub(crate) fn new(status: Option<&'a HeadsetStatus>, duplex: Option) -> Self { + Self { + name: status.and_then(|status| status.name.as_deref()), + connection: ConnectionState::from_flag(status.and_then(|status| status.connected)), + duplex, + rssi: status.and_then(|status| status.rssi), + } + } + + pub(crate) fn name_or(&self, fallback: &'a str) -> &'a str { + self.name.unwrap_or(fallback) + } + + pub(crate) fn dashboard_duplex_label(&self) -> &'static str { + match self.duplex { + Some(true) => "yes", + Some(false) => "no", + None => "?", + } + } + + pub(crate) fn panel_duplex_label(&self) -> &'static str { + if self.duplex == Some(true) { + "ready" + } else { + "unavailable" + } + } + + pub(crate) fn rssi_label(&self) -> String { + self.rssi + .map_or_else(|| "—".to_string(), |rssi| format!("{rssi} dBm")) + } + + pub(crate) fn dashboard_signal(&self) -> String { + match (self.connection, self.rssi) { + (ConnectionState::Connected, Some(rssi)) => format!( + "{rssi} dBm ({})", + SignalQuality::from_rssi(Some(rssi)).label() + ), + _ => "unknown".to_string(), + } + } +} + pub(crate) struct StatusUpdate { pub statuses: BTreeMap, pub error: Option, @@ -231,4 +350,46 @@ mod tests { assert!(!update.statuses["A"].duplex); assert_eq!(update.error.as_deref(), Some("PipeWire unavailable")); } + + #[test] + fn presentation_shares_status_semantics_without_collapsing_view_labels() { + let status = HeadsetStatus { + name: Some("Dispatch headset".into()), + connected: Some(true), + duplex: true, + rssi: Some(-67), + }; + let presentation = StatusPresentation::new(Some(&status), Some(status.duplex)); + + assert_eq!(presentation.name_or("fallback"), "Dispatch headset"); + assert_eq!(presentation.connection.dashboard_label(), "yes"); + assert_eq!(presentation.connection.panel_label(), "connected"); + assert_eq!(presentation.dashboard_duplex_label(), "yes"); + assert_eq!(presentation.panel_duplex_label(), "ready"); + assert_eq!(presentation.rssi_label(), "-67 dBm"); + assert_eq!(presentation.dashboard_signal(), "-67 dBm (good)"); + + let unknown = StatusPresentation::new(None, None); + assert_eq!(unknown.name_or("fallback"), "fallback"); + assert_eq!(unknown.connection.dashboard_label(), "?"); + assert_eq!(unknown.connection.panel_label(), "unknown"); + assert_eq!(unknown.dashboard_duplex_label(), "?"); + assert_eq!(unknown.panel_duplex_label(), "unavailable"); + assert_eq!(unknown.rssi_label(), "—"); + assert_eq!(unknown.dashboard_signal(), "unknown"); + + let disconnected_status = HeadsetStatus { + connected: Some(false), + rssi: Some(-40), + ..HeadsetStatus::default() + }; + let disconnected = StatusPresentation::new(Some(&disconnected_status), Some(false)); + assert_eq!(disconnected.dashboard_signal(), "unknown"); + assert_eq!(disconnected.rssi_label(), "-40 dBm"); + assert_eq!(SignalQuality::from_rssi(Some(-60)), SignalQuality::Strong); + assert_eq!(SignalQuality::from_rssi(Some(-70)), SignalQuality::Good); + assert_eq!(SignalQuality::from_rssi(Some(-80)), SignalQuality::Fair); + assert_eq!(SignalQuality::from_rssi(Some(-81)), SignalQuality::Weak); + assert_eq!(SignalQuality::from_rssi(None), SignalQuality::Unknown); + } } diff --git a/src/tui.rs b/src/tui.rs index 3e4b120..472c45c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -17,8 +17,9 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Table}; use ratatui::{DefaultTerminal, Frame}; -use crate::bluez::{FAIR_RSSI, GOOD_RSSI}; -use crate::device_status::{HeadsetStatus, StatusUpdate, start_polling}; +use crate::device_status::{ + ConnectionState, HeadsetStatus, SignalQuality, StatusPresentation, StatusUpdate, start_polling, +}; use crate::group_editor::GroupEditor; use crate::groups::load; use crate::terminal_style::{Meaning, Palette}; @@ -169,50 +170,43 @@ impl ControlPanel { .iter() .map(|address| { let status = self.status.get(address); - let name = status - .and_then(|status| status.name.as_deref()) - .unwrap_or("—"); - let connected_state = status.and_then(|status| status.connected); - let connected = match connected_state { - Some(true) => "connected", - Some(false) => "disconnected", - None => "unknown", + let presentation = StatusPresentation::new( + status, + Some(status.is_some_and(|status| status.duplex)), + ); + let connection_meaning = match presentation.connection { + ConnectionState::Connected => Meaning::Success, + ConnectionState::Disconnected => Meaning::Error, + ConnectionState::Unknown => Meaning::Warning, }; - let duplex_ready = status.is_some_and(|status| status.duplex); - let duplex = if duplex_ready { "ready" } else { "unavailable" }; - let rssi = status - .and_then(|status| status.rssi) - .map_or_else(|| "—".to_string(), |rssi| format!("{rssi} dBm")); - let connection_meaning = match connected_state { - Some(true) => Meaning::Success, - Some(false) => Meaning::Error, - None => Meaning::Warning, - }; - let signal_meaning = match status.and_then(|status| status.rssi) { - Some(rssi) if rssi >= GOOD_RSSI => Meaning::Success, - Some(rssi) if rssi >= FAIR_RSSI => Meaning::Warning, - Some(_) => Meaning::Error, - None => Meaning::Info, + let signal_meaning = match SignalQuality::from_rssi(presentation.rssi) { + SignalQuality::Strong | SignalQuality::Good => Meaning::Success, + SignalQuality::Fair => Meaning::Warning, + SignalQuality::Weak => Meaning::Error, + SignalQuality::Unknown => Meaning::Info, }; Row::new([ - Cell::from(name.to_string()), + Cell::from(presentation.name_or("—").to_string()), Cell::from(Span::styled( address.clone(), self.palette.style(Meaning::Info), )), Cell::from(Span::styled( - connected, + presentation.connection.panel_label(), self.palette.style(connection_meaning), )), Cell::from(Span::styled( - duplex, - self.palette.style(if duplex_ready { + presentation.panel_duplex_label(), + self.palette.style(if presentation.duplex == Some(true) { Meaning::Success } else { Meaning::Warning }), )), - Cell::from(Span::styled(rssi, self.palette.style(signal_meaning))), + Cell::from(Span::styled( + presentation.rssi_label(), + self.palette.style(signal_meaning), + )), ]) }) .collect() @@ -227,9 +221,11 @@ impl ControlPanel { .and_then(|index| self.group_editor.groups.get(index)) .is_some_and(|group| group.members.contains(address)); let status = self.status.get(address); - let name = status - .and_then(|status| status.name.as_deref()) - .unwrap_or(address.as_str()); + let presentation = StatusPresentation::new( + status, + Some(status.is_some_and(|status| status.duplex)), + ); + let name = presentation.name_or(address.as_str()); ListItem::new(Line::from(vec![ Span::styled( if included { "[x] " } else { "[ ] " },