diff --git a/Cargo.lock b/Cargo.lock index 7f77fde..0f81f81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2723,6 +2723,7 @@ dependencies = [ "reqwest", "rustls", "serde", + "serde_json", "sha2 0.11.1", "sinuous-client", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 5a00bed..5fc266c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,3 +36,6 @@ url = "2" [profile.release] strip = "symbols" + +[dev-dependencies] +serde_json = "1.0.154" diff --git a/README.md b/README.md index 53cc7a7..12bab72 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ sinuous --device "Living Room" | g | Switch to another group, from a panel showing what each group is playing | | G | Add speakers to or remove speakers from the group | | f | Play one of your Sonos favorites | +| i | System info: the details of each speaker, for troubleshooting | | , | Settings | | ? | Keyboard shortcuts | | q | Close what is open, or quit | diff --git a/src/sonos.rs b/src/sonos.rs index cac4766..851f252 100644 --- a/src/sonos.rs +++ b/src/sonos.rs @@ -14,7 +14,8 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use futures::StreamExt; use sinuous_client::{ - Event, EventPayload, FavoriteId, GroupId, Household, PlayerId, Subscription, Topology, + Event, EventPayload, FavoriteId, GroupId, Household, HouseholdId, PlayerId, Subscription, + Topology, favorites::Favorite, playback::{ LoadAction, LoadOptions, PlayModes, PlayModesUpdate, PlaybackActions, PlaybackState, @@ -63,6 +64,9 @@ impl ProvidedDevices { /// What the service knows about the household. #[derive(Debug, Clone, Default)] pub struct HouseholdState { + pub household_id: Option, + /// The groups and players as the household last reported them, with all their details. + pub topology: Arc, /// Sorted by name. pub groups: Vec, /// Sorted by name. @@ -147,6 +151,7 @@ pub struct PlayerInfo { #[derive(Debug, Clone, Copy)] pub struct Playback { + pub state: PlaybackState, pub is_playing: bool, pub play_modes: PlayModes, /// What the current source supports. @@ -173,6 +178,7 @@ impl Playback { fn from_parts(state: PlaybackState, position: Duration, now: Instant) -> Self { Self { + state, is_playing: matches!(state, PlaybackState::Playing | PlaybackState::Buffering), play_modes: PlayModes::default(), actions: PlaybackActions::default(), @@ -368,7 +374,10 @@ impl SonosHandle { /// /// The groups and speakers of the household are in the state right away. pub fn spawn(household: Household) -> SonosHandle { - let (state_tx, state_rx) = watch::channel(HouseholdState::default()); + let (state_tx, state_rx) = watch::channel(HouseholdState { + household_id: Some(household.id().clone()), + ..HouseholdState::default() + }); let (cmd_tx, cmd_rx) = mpsc::channel(COMMAND_QUEUE_SIZE); tokio::spawn(run_commands(household.clone(), cmd_rx, state_tx.clone())); @@ -536,11 +545,13 @@ impl Service { /// Update the groups and players from the household's topology, keeping what we know about /// them. fn update_groups(&mut self) { - let (groups, players) = { + let (topology, groups, players) = { let topology = self.topology_rx.borrow_and_update(); - (group_states(&topology), player_infos(&topology)) + let (groups, players) = (group_states(&topology), player_infos(&topology)); + (Arc::new(topology.clone()), groups, players) }; self.state_tx.send_modify(|state| { + state.topology = topology; 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); diff --git a/src/theme.rs b/src/theme.rs index 8202d91..a3f2098 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -57,6 +57,8 @@ pub struct Theme { pub text: Color, /// Less important text, e.g. labels. pub secondary: Color, + /// Labels standing out from their values, where both matter, e.g. in the system info. + pub label: Color, /// Unimportant text, e.g. separators, and actions that aren't supported. pub muted: Color, /// The name of the app, and titles. @@ -88,6 +90,7 @@ impl Theme { selection: None, text: Color::Reset, secondary: Color::Gray, + label: Color::Cyan, muted: Color::DarkGray, title: Color::Yellow, title_background: Color::DarkGray, @@ -116,6 +119,7 @@ impl Theme { selection: Some(color(tokens::BG_SELECTION)), text: color(tokens::TEXT_PRIMARY), secondary: color(tokens::TEXT_SECONDARY), + label: color(tokens::ACCENT_SECONDARY), muted: color(tokens::TEXT_DIM), title: color(tokens::ACCENT_PRIMARY), title_background: color(tokens::BG_HIGHLIGHT), diff --git a/src/view.rs b/src/view.rs index ef72ec5..7de51b0 100644 --- a/src/view.rs +++ b/src/view.rs @@ -21,6 +21,7 @@ mod input; mod now_playing; mod panel; mod popup; +mod system_info; pub use input::handle_input; use now_playing::{AlbumArt, now_playing_width, render_now_playing}; @@ -29,6 +30,7 @@ use popup::{ render_config_dialog, render_favorites_picker, render_help, render_playback_menu, render_theme_picker, render_volume_mixer, }; +use system_info::render_system_info; /// How long errors are displayed for. const ERROR_DISPLAY_TIME: Duration = Duration::from_secs(5); @@ -68,6 +70,8 @@ enum Mode { PlaybackMenu(usize), /// The keyboard shortcuts. Help, + /// The details of each speaker, in place of everything but the title bar. + SystemInfo(Scroll), /// The configuration dialog, and the theme picker on top of it, if open. Config { /// The setting highlighted: one of [`UiSetting::ALL`], or the theme after them. @@ -145,6 +149,15 @@ impl GroupEditor { } } +/// How far a fullscreen view is scrolled. +#[derive(Debug, Clone, Copy, Default)] +struct Scroll { + /// The first line shown. + offset: usize, + /// How many lines were shown last time, to scroll by pages. + page: usize, +} + /// The highlighted item of a list in the panel on the left, and the first one shown. #[derive(Debug, Clone, Copy)] struct PanelHighlight { @@ -200,16 +213,16 @@ impl UiState { Some(rows) => clamp(highlighted, rows), None => self.mode = Mode::NowPlaying, }, - Mode::NowPlaying | Mode::Help | Mode::Config { .. } => {} + Mode::NowPlaying | Mode::Help | Mode::SystemInfo(_) | Mode::Config { .. } => {} } } /// Whether a popup is open in the middle of the screen. The group panel and the group editor - /// are beside what is playing, not on top of it. + /// are beside what is playing, not on top of it, and the system info takes its place. fn popup_open(&self) -> bool { !matches!( self.mode, - Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) + Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) | Mode::SystemInfo(_) ) } @@ -285,6 +298,9 @@ pub fn render_ui( Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(frame.area()); render_title_bar(state, ui, group, symbols, theme, frame, title); + if let Mode::SystemInfo(scroll) = &mut ui.mode { + return render_system_info(state, theme, scroll, frame, body); + } let panel_help = match ui.mode { Mode::GroupPicker(_) => Some(GROUP_PANEL_HELP), Mode::GroupEditor(_) => Some(GROUP_EDITOR_HELP), @@ -346,7 +362,7 @@ pub fn render_ui( render_theme_picker(&settings.theme, theme, highlighted, frame); } } - Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) => {} + Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) | Mode::SystemInfo(_) => {} } } diff --git a/src/view/input.rs b/src/view/input.rs index 20b050d..599cc10 100644 --- a/src/view/input.rs +++ b/src/view/input.rs @@ -12,7 +12,7 @@ use crate::config::{UiConfig, UiSetting}; use crate::sonos::{Command, GroupAction, GroupState, HouseholdState, PlayerAction}; use crate::theme::THEMES; -use super::{GroupEditor, Mode, PanelHighlight, PlaybackItem, Request, Selection, UiState}; +use super::{GroupEditor, Mode, PanelHighlight, PlaybackItem, Request, Scroll, Selection, UiState}; /// How much the volume changes with each key press. const VOLUME_STEP: i8 = 1; @@ -55,6 +55,10 @@ pub fn handle_input( Mode::PlaybackMenu(highlighted) => { handle_playback_menu_input(input, state, ui, highlighted) } + Mode::SystemInfo(scroll) => { + handle_system_info_input(input, ui, scroll); + None + } Mode::Help => { if matches!(input.code, KeyCode::Esc | KeyCode::Char('?')) { ui.mode = Mode::NowPlaying; @@ -93,6 +97,7 @@ fn handle_now_playing_input( KeyCode::Char('.') => Mode::PlaybackMenu(0), KeyCode::Char('?') => Mode::Help, + KeyCode::Char('i') => Mode::SystemInfo(Scroll::default()), KeyCode::Char(',') => Mode::Config { highlighted: 0, theme_picker: None, @@ -169,6 +174,26 @@ fn handle_theme_picker_input( None } +/// Update `ui` according to the key pressed while the system info is open, scrolled to `scroll`. +fn handle_system_info_input(input: &KeyEvent, ui: &mut UiState, scroll: Scroll) { + // Scrolling past the end is undone when drawing it, which is when its length is known. + let page = scroll.page.max(1); + let offset = match input.code { + KeyCode::Up | KeyCode::Char('k') => scroll.offset.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => scroll.offset.saturating_add(1), + KeyCode::PageUp => scroll.offset.saturating_sub(page), + KeyCode::PageDown => scroll.offset.saturating_add(page), + KeyCode::Home => 0, + KeyCode::End => usize::MAX, + KeyCode::Esc | KeyCode::Char('i') => { + ui.mode = Mode::NowPlaying; + return; + } + _ => return, + }; + ui.mode = Mode::SystemInfo(Scroll { offset, ..scroll }); +} + /// Update `ui` according to the key pressed while the group picker is open, with the group at /// `highlight` highlighted. fn handle_group_picker_input( @@ -618,7 +643,7 @@ mod tests { let mut ui = UiState::default(); ui.sync(&state); - for opening in [',', 'v', '.', 'f', 'g', 'G', '?'] { + for opening in [',', 'v', '.', 'f', 'g', 'G', 'i', '?'] { assert!(press(&[KeyCode::Char(opening)], &state, &mut ui).is_none()); assert!(!matches!(ui.mode, Mode::NowPlaying), "{opening}"); assert!(press(&[KeyCode::Char('q')], &state, &mut ui).is_none()); diff --git a/src/view/popup.rs b/src/view/popup.rs index 5223dca..b03d060 100644 --- a/src/view/popup.rs +++ b/src/view/popup.rs @@ -180,6 +180,7 @@ pub(super) fn render_help(theme: &Theme, frame: &mut Frame) { ("f", "Play a favorite"), ("g", "Switch to another group"), ("G", "Add or remove speakers from the group"), + ("i", "System info, for troubleshooting"), (",", "Configuration"), ("?", "This help"), ("q", "Close what is open, or quit"), diff --git a/src/view/system_info.rs b/src/view/system_info.rs new file mode 100644 index 0000000..e116b30 --- /dev/null +++ b/src/view/system_info.rs @@ -0,0 +1,415 @@ +//! The system info: the details of each speaker, for troubleshooting. + +use ratatui::{ + Frame, + layout::{Margin, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{ + Block, BorderType::Rounded, Padding, Paragraph, Scrollbar, ScrollbarOrientation, + ScrollbarState, + }, +}; +use sinuous_client::{ + groups::{Capability, DeviceInfo, Player}, + playback::PlaybackState, +}; + +use crate::sonos::{HouseholdState, PlayerInfo}; +use crate::theme::Theme; + +use super::Scroll; + +/// The keys to use in the system info. +const HELP: &str = " ↑↓ scroll • PGUP/PGDN page • ESC close "; + +/// How wide labels are, for the values to line up. +const LABEL_WIDTH: usize = 14; + +/// How far the details of the devices of a speaker are indented. +const DEVICE_INDENT: usize = 2; + +/// The details of the household and of each of its speakers, in `area`, scrolled to `scroll`. +pub(super) fn render_system_info( + state: &HouseholdState, + theme: &Theme, + scroll: &mut Scroll, + frame: &mut Frame, + area: Rect, +) { + let block = Block::bordered() + .border_type(Rounded) + .border_style(Style::default().fg(theme.border)) + .padding(Padding::horizontal(1)) + .title_top(" System info ") + .title_bottom( + Line::from(HELP) + .centered() + .style(Style::default().fg(theme.muted)), + ); + let inner = block.inner(area); + let lines = system_info_lines(state, theme, usize::from(inner.width)); + + // Don't scroll past the last line. + let len = lines.len(); + scroll.page = usize::from(inner.height); + let max_offset = len.saturating_sub(scroll.page); + scroll.offset = scroll.offset.min(max_offset); + + let offset = u16::try_from(scroll.offset).unwrap_or(u16::MAX); + frame.render_widget(Paragraph::new(lines).block(block).scroll((offset, 0)), area); + if max_offset > 0 { + let mut scrollbar = ScrollbarState::new(max_offset).position(scroll.offset); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .track_style(Style::default().fg(theme.border)) + .thumb_style(Style::default().fg(theme.secondary)), + area.inner(Margin::new(0, 1)), + &mut scrollbar, + ); + } +} + +/// The lines to show, with long lists wrapped to fit in `width`. +fn system_info_lines(state: &HouseholdState, theme: &Theme, width: usize) -> Vec> { + let rows = Rows { theme, width }; + let household = state + .household_id + .as_ref() + .map_or_else(|| "unknown".to_owned(), ToString::to_string); + let mut lines = vec![ + rows.row(0, "Household", household), + rows.row(0, "Speakers", state.players.len().to_string()), + rows.row(0, "Groups", state.groups.len().to_string()), + ]; + for player in &state.players { + lines.push(Line::default()); + lines.extend(player_lines(state, player, &rows)); + } + lines +} + +/// The details of `player`, and of each of its devices. +fn player_lines(state: &HouseholdState, player: &PlayerInfo, rows: &Rows) -> Vec> { + let theme = rows.theme; + let mut lines = vec![Line::styled( + player.name.clone(), + Style::default() + .fg(theme.title) + .add_modifier(Modifier::BOLD), + )]; + + if let Some(group) = state + .groups + .iter() + .find(|g| g.player_ids.contains(&player.id)) + { + let mut value = group.name.clone(); + if group.coordinator_id == player.id { + value.push_str(" · coordinator"); + } + if let Some(playback) = group.playback { + value.push_str(" · "); + value.push_str(playback_state(playback.state)); + } + lines.push(rows.row(0, "Group", value)); + } + let volume = player + .volume + .map_or_else(|| "unknown".to_owned(), |volume| volume.to_string()); + lines.push(rows.row(0, "Volume", volume)); + + let Some(details) = state.topology.players.get(&player.id) else { + return lines; + }; + lines.extend([ + rows.row(0, "Address", address(&details.websocket_url)), + rows.row(0, "ID", details.id.to_string()), + rows.row(0, "Software", details.software_version.clone()), + rows.row( + 0, + "API version", + format!("{} (min {})", details.api_version, details.min_api_version), + ), + ]); + let registered = if details.is_unregistered { + Span::styled("no", Style::default().fg(theme.error)) + } else { + Span::raw("yes") + }; + lines.push(rows.row(0, "Registered", registered)); + lines.extend(rows.list(0, "Capabilities", capabilities(details.capabilities.iter()))); + + for device in &details.devices { + lines.extend(device_lines(details, device, rows)); + } + lines +} + +/// The details of `device`, one of the devices making up `player`. +fn device_lines(player: &Player, device: &DeviceInfo, rows: &Rows) -> Vec> { + let mut lines = vec![ + rows.row( + 0, + "Device", + Span::styled( + device.model_display_name.clone(), + Style::default().fg(rows.theme.name), + ), + ), + rows.row(DEVICE_INDENT, "Model", device.model.clone()), + ]; + // The main device of a player has its ID and address. + if device.id != player.id { + lines.push(rows.row(DEVICE_INDENT, "ID", device.id.to_string())); + lines.push(rows.row(DEVICE_INDENT, "Address", address(&device.websocket_url))); + } + lines.extend([ + rows.row(DEVICE_INDENT, "Serial", device.serial_number.clone()), + rows.row(DEVICE_INDENT, "Hardware", device.hw_version.clone()), + rows.row( + DEVICE_INDENT, + "Software", + format!("{} (Sonos S{})", device.software_version, device.sw_gen), + ), + ]); + if let Some(color) = &device.color { + lines.push(rows.row(DEVICE_INDENT, "Colour", color.clone())); + } + lines +} + +/// Lays out labelled values. +struct Rows<'a> { + theme: &'a Theme, + /// How wide lines may be. + width: usize, +} + +impl Rows<'_> { + /// `value`, labelled with `label`, indented by `indent`. + fn row(&self, indent: usize, label: &str, value: impl Into>) -> Line<'static> { + let label = format!( + "{}{label:) -> Vec> { + let available = self.width.saturating_sub(LABEL_WIDTH).max(1); + let mut values: Vec = Vec::new(); + for (i, item) in items.iter().enumerate() { + let item = if i + 1 < items.len() { + format!("{item},") + } else { + (*item).to_owned() + }; + match values.last_mut() { + Some(line) if line.len() + 1 + item.len() <= available => { + line.push(' '); + line.push_str(&item); + } + _ => values.push(item), + } + } + if values.is_empty() { + values.push("none".to_owned()); + } + values + .into_iter() + .enumerate() + .map(|(i, value)| self.row(indent, if i == 0 { label } else { "" }, value)) + .collect() + } +} + +/// The IP address (or host name) of a speaker, from the URL of its websocket. +fn address(websocket_url: &url::Url) -> String { + websocket_url + .host_str() + .map_or_else(|| websocket_url.to_string(), ToOwned::to_owned) +} + +fn playback_state(state: PlaybackState) -> &'static str { + match state { + PlaybackState::Buffering => "buffering", + PlaybackState::Idle => "idle", + PlaybackState::Paused => "paused", + PlaybackState::Playing => "playing", + PlaybackState::Unknown => "unknown", + } +} + +/// The names of `capabilities`, as the Sonos API calls them, to be looked up in its documentation. +fn capabilities(capabilities: impl Iterator) -> Vec<&'static str> { + capabilities + .map(|capability| match capability { + Capability::Playback => "PLAYBACK", + Capability::Cloud => "CLOUD", + Capability::HtPlayback => "HT_PLAYBACK", + Capability::HtPowerState => "HT_POWER_STATE", + Capability::Airplay => "AIRPLAY", + Capability::LineIn => "LINE_IN", + Capability::AudioClip => "AUDIO_CLIP", + Capability::Voice => "VOICE", + Capability::SpeakerDetection => "SPEAKER_DETECTION", + Capability::FixedVolume => "FIXED_VOLUME", + Capability::MicrophoneSwitch => "MICROPHONE_SWITCH", + Capability::IrControl => "IR_CONTROL", + Capability::Hdmi => "HDMI", + Capability::Svc => "SVC", + Capability::Unknown => "unknown", + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crossterm::event::KeyCode; + use ratatui::{Terminal, backend::TestBackend}; + use sinuous_client::Topology; + + use super::*; + use crate::artwork::Artwork; + use crate::config::UiConfig; + use crate::view::tests::{household, press}; + use crate::view::{UiState, render_ui}; + + /// The bedroom, as a soundbar with a sub. + fn bedroom() -> Player { + let device = |id: &str, model: &str, name: &str, ip: &str| { + serde_json::json!({ + "_objectType": "deviceInfo", + "id": id, + "name": "Bedroom", + "serialNumber": format!("{id}-serial"), + "model": model, + "modelDisplayName": name, + "color": "Black", + "capabilities": ["PLAYBACK"], + "apiVersion": "1.44.1", + "minApiVersion": "1.1.0", + "websocketUrl": format!("wss://{ip}:1443/websocket/api"), + "softwareVersion": "85.0-65270", + "hwVersion": "1.36.1.2-1.0", + "swGen": 2, + }) + }; + serde_json::from_value(serde_json::json!({ + "_objectType": "player", + "id": "b", + "name": "Bedroom", + "websocketUrl": "wss://10.0.0.2:1443/websocket/api", + "apiVersion": "1.44.1", + "minApiVersion": "1.1.0", + "softwareVersion": "85.0-65270", + "capabilities": [ + "PLAYBACK", "CLOUD", "HT_PLAYBACK", "HT_POWER_STATE", "AIRPLAY", + "AUDIO_CLIP", "VOICE", "SPEAKER_DETECTION", "FIXED_VOLUME", + ], + "isUnregistered": false, + "deviceIds": ["b", "sub"], + "devices": [ + device("b", "S45", "Arc Ultra", "10.0.0.2"), + device("sub", "S41", "Sub 4", "10.0.0.3"), + ], + })) + .unwrap() + } + + fn screen(state: &HouseholdState, ui: &mut UiState, width: u16, height: u16) -> Vec { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let (mut artwork, _) = Artwork::new(ratatui_image::picker::Picker::halfblocks(), None); + terminal + .draw(|f| { + render_ui( + f, + state, + ui, + &mut artwork, + &UiConfig::default(), + &Theme::TERMINAL, + ); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect() + }) + .collect() + } + + fn state() -> HouseholdState { + let mut topology = Topology::default(); + topology.players.insert_overwrite(bedroom()); + HouseholdState { + household_id: Some("Sonos_1".into()), + topology: Arc::new(topology), + ..household() + } + } + + #[test] + fn shows_the_details_of_speakers_and_their_devices() { + let state = state(); + let mut ui = UiState::default(); + ui.sync(&state); + press(&[KeyCode::Char('i')], &state, &mut ui); + + let screen = screen(&state, &mut ui, 60, 60).join("\n"); + for shown in [ + "Household Sonos_1", + "Group Bedroom + Office · coordinator", + "Address 10.0.0.2", + "Device Arc Ultra", + " Model S45", + "Device Sub 4", + " Address 10.0.0.3", + " Software 85.0-65270 (Sonos S2)", + // Speakers the topology doesn't know about yet still show what is known. + "Kitchen", + ] { + assert!(screen.contains(shown), "{shown}\n{screen}"); + } + // The capabilities are wrapped, and lined up. + assert!( + screen.contains("Capabilities PLAYBACK, CLOUD,"), + "{screen}" + ); + assert!( + screen.contains(" VOICE, SPEAKER_DETECTION"), + "{screen}" + ); + // The main device has the speaker's address, so it isn't repeated. + assert_eq!(screen.matches("10.0.0.2").count(), 1, "{screen}"); + } + + #[test] + fn scrolls_no_further_than_the_last_line() { + let state = state(); + let mut ui = UiState::default(); + ui.sync(&state); + press(&[KeyCode::Char('i'), KeyCode::End], &state, &mut ui); + + 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:#?}"); + + press(&[KeyCode::Down], &state, &mut ui); + assert_eq!(screen(&state, &mut ui, 60, 12), at_end); + } +}