From cdd283c4c134ad690979a43ab83849676408adde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Sat, 17 Oct 2026 00:49:27 +1100 Subject: [PATCH] feat: Show the battery and Wi-Fi of each speaker in the system info Subscribes to each speaker's hardware status, for the charge, charging state and temperature of portable speakers' batteries, and the network, access point, band, channel, signal and connection health of their Wi-Fi. Co-Authored-By: Claude Opus 5.5 --- src/sonos.rs | 38 ++++++++---- src/view.rs | 2 + src/view/system_info.rs | 125 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 150 insertions(+), 15 deletions(-) diff --git a/src/sonos.rs b/src/sonos.rs index b4f14d6..db6102f 100644 --- a/src/sonos.rs +++ b/src/sonos.rs @@ -17,6 +17,7 @@ use sinuous_client::{ Event, EventPayload, FavoriteId, GroupId, Household, HouseholdId, PlayerId, Subscription, Topology, favorites::Favorite, + hardware_status::{BatteryStatus, WirelessNetworkStatus}, playback::{ LoadAction, LoadOptions, PlayModes, PlayModesUpdate, PlaybackActions, PlaybackState, PlaybackStatus, @@ -150,6 +151,10 @@ pub struct PlayerInfo { /// The model of its main device, e.g. `Arc Ultra` for a home theatre, if it is known. pub model: Option, pub volume: Option, + /// The battery of a portable player, once it has reported it. + pub battery: Option, + /// Its Wi-Fi connection, once it has reported it. Players connected by Ethernet don't. + pub wifi: Option, } #[derive(Debug, Clone, Copy)] @@ -472,13 +477,20 @@ impl Service { "Playback error: {}", err.reason.unwrap_or(err.error_code) )), - EventPayload::PlayerVolume(volume) => { + payload @ (EventPayload::PlayerVolume(_) + | EventPayload::BatteryStatus(_) + | EventPayload::WirelessNetworkStatus(_)) => { let Some(id) = event.player_id else { return }; self.state_tx.send_if_modified(|state| { let Some(player) = state.players.iter_mut().find(|p| p.id == id) else { return false; }; - player.volume = Some(volume.volume); + match payload { + EventPayload::PlayerVolume(volume) => player.volume = Some(volume.volume), + EventPayload::BatteryStatus(battery) => player.battery = Some(battery), + EventPayload::WirelessNetworkStatus(wifi) => player.wifi = Some(wifi), + _ => return false, + } true }); } @@ -565,7 +577,7 @@ impl Service { let old_groups = std::mem::replace(&mut state.groups, groups); keep_group_states(&mut state.groups, &old_groups); let old_players = std::mem::replace(&mut state.players, players); - keep_player_volumes(&mut state.players, &old_players); + keep_player_states(&mut state.players, &old_players); }); self.subscribe_to_members(); } @@ -585,10 +597,12 @@ impl Service { Subscription::SleepTimer(group.id.clone()), ] }); - let players = state - .players - .iter() - .map(|player| Subscription::PlayerVolume(player.id.clone())); + let players = state.players.iter().flat_map(|player| { + [ + Subscription::PlayerVolume(player.id.clone()), + Subscription::HardwareStatus(player.id.clone()), + ] + }); groups.chain(players).collect() }; self.subscribed.retain(|s| wanted.contains(s)); @@ -658,7 +672,7 @@ impl Service { | Subscription::PlaybackMetadata(id) | Subscription::GroupVolume(id) | Subscription::SleepTimer(id) => state.group(id).map(|g| g.name.clone()), - Subscription::PlayerVolume(id) => state + Subscription::PlayerVolume(id) | Subscription::HardwareStatus(id) => state .players .iter() .find(|p| &p.id == id) @@ -909,6 +923,8 @@ fn player_infos(topology: &Topology) -> Vec { .or(player.devices.first()) .map(|device| device.model_display_name.clone()), volume: None, + battery: None, + wifi: None, }) .collect(); players.sort_by(|a, b| a.name.cmp(&b.name)); @@ -929,11 +945,13 @@ fn keep_group_states(groups: &mut [GroupState], old_groups: &[GroupState]) { } } -/// Copy the volumes of the players in `old_players` to the same players in `players`. -fn keep_player_volumes(players: &mut [PlayerInfo], old_players: &[PlayerInfo]) { +/// Copy what we know about the players in `old_players` to the same players in `players`. +fn keep_player_states(players: &mut [PlayerInfo], old_players: &[PlayerInfo]) { for player in players { if let Some(old) = old_players.iter().find(|old| old.id == player.id) { player.volume = old.volume; + player.battery = old.battery; + player.wifi.clone_from(&old.wifi); } } } diff --git a/src/view.rs b/src/view.rs index 33db122..221bb76 100644 --- a/src/view.rs +++ b/src/view.rs @@ -498,6 +498,8 @@ mod tests { name: name.to_owned(), model: Some("Move".to_owned()), volume: Some(20), + battery: None, + wifi: None, }; HouseholdState { groups: vec![ diff --git a/src/view/system_info.rs b/src/view/system_info.rs index e116b30..5d775e0 100644 --- a/src/view/system_info.rs +++ b/src/view/system_info.rs @@ -12,6 +12,9 @@ use ratatui::{ }; use sinuous_client::{ groups::{Capability, DeviceInfo, Player}, + hardware_status::{ + BatteryStatus, ChargerValidity, ChargingState, ConnectionHealth, WirelessNetworkStatus, + }, playback::PlaybackState, }; @@ -26,6 +29,9 @@ const HELP: &str = " ↑↓ scroll • PGUP/PGDN page • ESC close "; /// How wide labels are, for the values to line up. const LABEL_WIDTH: usize = 14; +/// Below this charge, in percent, the battery is shown as low. +const LOW_BATTERY: f64 = 20.0; + /// How far the details of the devices of a speaker are indented. const DEVICE_INDENT: usize = 2; @@ -120,6 +126,17 @@ fn player_lines(state: &HouseholdState, player: &PlayerInfo, rows: &Rows) -> Vec .volume .map_or_else(|| "unknown".to_owned(), |volume| volume.to_string()); lines.push(rows.row(0, "Volume", volume)); + if let Some(battery) = &player.battery { + lines.push(rows.row(0, "Battery", battery_text(battery, theme))); + } + match &player.wifi { + Some(wifi) => lines.extend(wifi_rows(wifi, rows)), + None => lines.push(rows.row( + 0, + "Wi-Fi", + Span::styled("not reported", Style::default().fg(theme.muted)), + )), + } let Some(details) = state.topology.players.get(&player.id) else { return lines; @@ -181,6 +198,73 @@ fn device_lines(player: &Player, device: &DeviceInfo, rows: &Rows) -> Vec Span<'static> { + let mut text = format!("{:.0}%", battery.battery_percentage); + if let Some(raw) = battery + .raw_battery_percentage + .filter(|raw| raw.round() != battery.battery_percentage.round()) + { + text.push_str(&format!(" (measured {raw:.0}%)")); + } + text.push_str(match battery.charging_state { + ChargingState::Charging => " · charging", + ChargingState::NotCharging => " · not charging", + ChargingState::Unknown => "", + }); + if battery.valid_charger == ChargerValidity::Valid { + text.push_str(" · on its charger"); + } + if let Some(temperature) = battery.battery_temperature { + text.push_str(&format!(" · {temperature:.1} °C")); + } + let low = battery.battery_percentage < LOW_BATTERY + && battery.charging_state != ChargingState::Charging; + if low { + Span::styled(text, Style::default().fg(theme.error)) + } else { + Span::raw(text) + } +} + +/// The network `wifi` is connected to, and to which access point and how. +fn wifi_rows(wifi: &WirelessNetworkStatus, rows: &Rows) -> Vec> { + let health = match wifi.connection_health { + ConnectionHealth::Excellent => "excellent", + ConnectionHealth::Good => "good", + // Players may report weaker connections with values the client doesn't know yet. + ConnectionHealth::Unknown => "other", + }; + let mut lines = vec![rows.row(0, "Wi-Fi", format!("{} · {health}", wifi.ssid))]; + + let mut access_point = Vec::new(); + if let Some(bssid) = &wifi.bssid { + access_point.push(bssid.clone()); + } + if let Some(frequency) = wifi.frequency { + access_point.push(band(frequency).to_owned()); + } + if let Some(channel) = wifi.channel { + access_point.push(format!("channel {channel}")); + } + if let Some(rssi) = wifi.rssi { + access_point.push(format!("signal {rssi}")); + } + if !access_point.is_empty() { + lines.push(rows.row(0, "Access point", access_point.join(" · "))); + } + lines +} + +/// The Wi-Fi band a frequency in MHz is in. +fn band(frequency: u32) -> &'static str { + match frequency { + ..3000 => "2.4 GHz", + 3000..5925 => "5 GHz", + 5925.. => "6 GHz", + } +} + /// Lays out labelled values. struct Rows<'a> { theme: &'a Theme, @@ -356,11 +440,37 @@ mod tests { fn state() -> HouseholdState { let mut topology = Topology::default(); topology.players.insert_overwrite(bedroom()); - HouseholdState { + let mut state = HouseholdState { household_id: Some("Sonos_1".into()), topology: Arc::new(topology), ..household() - } + }; + let bedroom = &mut state.players[0]; + bedroom.battery = Some( + serde_json::from_value(serde_json::json!({ + "_objectType": "battery", + "batteryPercentage": 15.0, + "rawBatteryPercentage": 12.0, + "chargingState": "NOT_CHARGING", + "batteryTemperature": 31.5, + "validCharger": "NOT_APPLICABLE", + })) + .unwrap(), + ); + bedroom.wifi = Some( + serde_json::from_value(serde_json::json!({ + "_objectType": "wirelessNetworkStatus", + // "Home", base64-encoded + "ssid": "SG9tZQ==", + "bssid": "aa:bb:cc:dd:ee:ff", + "frequency": 5320, + "channel": 64, + "rssi": 45, + "connectionHealth": "EXCELLENT", + })) + .unwrap(), + ); + state } #[test] @@ -370,11 +480,16 @@ mod tests { ui.sync(&state); press(&[KeyCode::Char('i')], &state, &mut ui); - let screen = screen(&state, &mut ui, 60, 60).join("\n"); + let screen = screen(&state, &mut ui, 80, 60).join("\n"); for shown in [ "Household Sonos_1", "Group Bedroom + Office · coordinator", "Address 10.0.0.2", + "Battery 15% (measured 12%) · not charging · 31.5 °C", + "Wi-Fi Home · excellent", + "Access point aa:bb:cc:dd:ee:ff · 5 GHz · channel 64 · signal 45", + // The kitchen hasn't reported its Wi-Fi, e.g. because it's wired. + "Wi-Fi not reported", "Device Arc Ultra", " Model S45", "Device Sub 4", @@ -391,7 +506,7 @@ mod tests { "{screen}" ); assert!( - screen.contains(" VOICE, SPEAKER_DETECTION"), + screen.contains(" AUDIO_CLIP, VOICE, SPEAKER_DETECTION"), "{screen}" ); // The main device has the speaker's address, so it isn't repeated. @@ -407,7 +522,7 @@ mod tests { let at_end = screen(&state, &mut ui, 60, 12); // The title bar, then the border: the last line is just above the bottom border. - assert!(at_end[10].contains("Volume"), "{at_end:#?}"); + assert!(at_end[10].contains("Wi-Fi"), "{at_end:#?}"); press(&[KeyCode::Down], &state, &mut ui); assert_eq!(screen(&state, &mut ui, 60, 12), at_end);