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: 28 additions & 10 deletions src/sonos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
pub volume: Option<u8>,
/// The battery of a portable player, once it has reported it.
pub battery: Option<BatteryStatus>,
/// Its Wi-Fi connection, once it has reported it. Players connected by Ethernet don't.
pub wifi: Option<WirelessNetworkStatus>,
}

#[derive(Debug, Clone, Copy)]
Expand Down Expand Up @@ -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
});
}
Expand Down Expand Up @@ -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();
}
Expand All @@ -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));
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -909,6 +923,8 @@ fn player_infos(topology: &Topology) -> Vec<PlayerInfo> {
.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));
Expand All @@ -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);
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![
Expand Down
125 changes: 120 additions & 5 deletions src/view/system_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ use ratatui::{
};
use sinuous_client::{
groups::{Capability, DeviceInfo, Player},
hardware_status::{
BatteryStatus, ChargerValidity, ChargingState, ConnectionHealth, WirelessNetworkStatus,
},
playback::PlaybackState,
};

Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -181,6 +198,73 @@ fn device_lines(player: &Player, device: &DeviceInfo, rows: &Rows) -> Vec<Line<'
lines
}

/// The charge of `battery`, with whether it is charging, and its temperature.
fn battery_text(battery: &BatteryStatus, theme: &Theme) -> 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<Line<'static>> {
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,
Expand Down Expand Up @@ -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]
Expand All @@ -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",
Expand All @@ -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.
Expand All @@ -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);
Expand Down
Loading