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);