Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 8 additions & 30 deletions src/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -85,42 +84,21 @@ impl Dashboard {
)?;
for address in allowed {
let bluetooth = self.state.get(address);
let status = |flag: Option<bool>| 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::<String>(),
status(bluetooth.and_then(|info| info.connected)),
status(headset.map(Headset::has_duplex_audio))
presentation.connection.dashboard_label(),
presentation.dashboard_duplex_label()
)?;
}
writeln!(
Expand Down
163 changes: 162 additions & 1 deletion src/device_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<bool>) -> 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<i32>) -> 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<bool>,
pub(crate) rssi: Option<i32>,
}

impl<'a> StatusPresentation<'a> {
pub(crate) fn new(status: Option<&'a HeadsetStatus>, duplex: Option<bool>) -> 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<String, HeadsetStatus>,
pub error: Option<String>,
Expand Down Expand Up @@ -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);
}
}
62 changes: 29 additions & 33 deletions src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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()
Expand All @@ -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 { "[ ] " },
Expand Down