use serde::Deserialize;
use sonos_api::Service;
use sonos_stream::events::{
AVTransportState, EnrichedEvent, EventData, GroupRenderingControlState, RenderingControlState,
ZoneGroupTopologyState,
};
use url::{Host, Url};
use std::net::IpAddr;
use crate::model::{GroupId, SpeakerId};
use crate::property::{
Bass, CurrentTrack, GroupInfo, GroupMembership, GroupMute, GroupVolume, GroupVolumeChangeable,
Loudness, Mute, PlaybackState, Position, Treble, Volume,
};
use crate::state::{StateStore, WriteOutcome, WriteStamp};
#[derive(Debug)]
pub struct DecodedChanges {
pub speaker_id: SpeakerId,
pub changes: Vec<PropertyChange>,
}
#[derive(Debug)]
pub struct TopologyChanges {
pub groups: Vec<GroupInfo>,
pub memberships: Vec<(SpeakerId, GroupMembership)>,
pub boot_seqs: Vec<(SpeakerId, u32)>,
pub speaker_ips: Vec<(SpeakerId, IpAddr)>,
pub satellite_ids: Vec<SpeakerId>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum PropertyChange {
Volume(Volume),
Mute(Mute),
Bass(Bass),
Treble(Treble),
Loudness(Loudness),
PlaybackState(PlaybackState),
Position(Position),
CurrentTrack(CurrentTrack),
GroupMembership(GroupMembership),
GroupVolume(GroupVolume),
GroupMute(GroupMute),
GroupVolumeChangeable(GroupVolumeChangeable),
}
impl PropertyChange {
pub fn apply(
&self,
store: &mut StateStore,
speaker_id: &SpeakerId,
stamp: WriteStamp,
) -> WriteOutcome {
match self {
PropertyChange::Volume(v) => store.set(speaker_id, v.clone(), stamp),
PropertyChange::Mute(v) => store.set(speaker_id, v.clone(), stamp),
PropertyChange::Bass(v) => store.set(speaker_id, v.clone(), stamp),
PropertyChange::Treble(v) => store.set(speaker_id, v.clone(), stamp),
PropertyChange::Loudness(v) => store.set(speaker_id, v.clone(), stamp),
PropertyChange::PlaybackState(v) => store.set(speaker_id, v.clone(), stamp),
PropertyChange::Position(v) => store.set(speaker_id, v.clone(), stamp),
PropertyChange::CurrentTrack(v) => store.set(speaker_id, v.clone(), stamp),
PropertyChange::GroupMembership(v) => store.set(speaker_id, v.clone(), stamp),
PropertyChange::GroupVolume(v) => {
match store.speaker_to_group.get(speaker_id).cloned() {
Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
None => self.log_unmapped_group_change(speaker_id),
}
}
PropertyChange::GroupMute(v) => match store.speaker_to_group.get(speaker_id).cloned() {
Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
None => self.log_unmapped_group_change(speaker_id),
},
PropertyChange::GroupVolumeChangeable(v) => {
match store.speaker_to_group.get(speaker_id).cloned() {
Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
None => self.log_unmapped_group_change(speaker_id),
}
}
}
}
fn log_unmapped_group_change(&self, speaker_id: &SpeakerId) -> WriteOutcome {
tracing::warn!(
"Dropping group-scoped {} change for {}: speaker has no group mapping \
(no topology snapshot applied yet, or group state was cleared)",
self.key(),
speaker_id.as_str()
);
WriteOutcome::Unchanged
}
pub fn key(&self) -> &'static str {
use crate::property::Property;
match self {
PropertyChange::Volume(_) => Volume::KEY,
PropertyChange::Mute(_) => Mute::KEY,
PropertyChange::Bass(_) => Bass::KEY,
PropertyChange::Treble(_) => Treble::KEY,
PropertyChange::Loudness(_) => Loudness::KEY,
PropertyChange::PlaybackState(_) => PlaybackState::KEY,
PropertyChange::Position(_) => Position::KEY,
PropertyChange::CurrentTrack(_) => CurrentTrack::KEY,
PropertyChange::GroupMembership(_) => GroupMembership::KEY,
PropertyChange::GroupVolume(_) => GroupVolume::KEY,
PropertyChange::GroupMute(_) => GroupMute::KEY,
PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::KEY,
}
}
pub fn scope(&self) -> crate::property::Scope {
use crate::property::SonosProperty;
match self {
PropertyChange::Volume(_) => Volume::SCOPE,
PropertyChange::Mute(_) => Mute::SCOPE,
PropertyChange::Bass(_) => Bass::SCOPE,
PropertyChange::Treble(_) => Treble::SCOPE,
PropertyChange::Loudness(_) => Loudness::SCOPE,
PropertyChange::PlaybackState(_) => PlaybackState::SCOPE,
PropertyChange::Position(_) => Position::SCOPE,
PropertyChange::CurrentTrack(_) => CurrentTrack::SCOPE,
PropertyChange::GroupMembership(_) => GroupMembership::SCOPE,
PropertyChange::GroupVolume(_) => GroupVolume::SCOPE,
PropertyChange::GroupMute(_) => GroupMute::SCOPE,
PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::SCOPE,
}
}
pub fn service(&self) -> Service {
use crate::property::SonosProperty;
match self {
PropertyChange::Volume(_) => Volume::SERVICE,
PropertyChange::Mute(_) => Mute::SERVICE,
PropertyChange::Bass(_) => Bass::SERVICE,
PropertyChange::Treble(_) => Treble::SERVICE,
PropertyChange::Loudness(_) => Loudness::SERVICE,
PropertyChange::PlaybackState(_) => PlaybackState::SERVICE,
PropertyChange::Position(_) => Position::SERVICE,
PropertyChange::CurrentTrack(_) => CurrentTrack::SERVICE,
PropertyChange::GroupMembership(_) => GroupMembership::SERVICE,
PropertyChange::GroupVolume(_) => GroupVolume::SERVICE,
PropertyChange::GroupMute(_) => GroupMute::SERVICE,
PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::SERVICE,
}
}
}
pub fn decode_event(event: &EnrichedEvent, speaker_id: SpeakerId) -> DecodedChanges {
let changes = match &event.event_data {
EventData::RenderingControl(rc) => decode_rendering_control(rc),
EventData::AVTransport(avt) => decode_av_transport(avt),
EventData::ZoneGroupTopology(zgt) => decode_topology(zgt),
EventData::DeviceProperties(_) => vec![],
EventData::GroupManagement(_) => vec![],
EventData::GroupRenderingControl(grc) => decode_group_rendering_control(grc),
};
DecodedChanges {
speaker_id,
changes,
}
}
fn decode_rendering_control(event: &RenderingControlState) -> Vec<PropertyChange> {
let mut changes = vec![];
if let Some(vol_str) = &event.master_volume {
if let Ok(vol) = vol_str.parse::<u8>() {
changes.push(PropertyChange::Volume(Volume(vol.min(100))));
}
}
if let Some(mute_str) = &event.master_mute {
let muted = mute_str == "1" || mute_str.eq_ignore_ascii_case("true");
changes.push(PropertyChange::Mute(Mute(muted)));
}
if let Some(bass_str) = &event.bass {
if let Ok(bass) = bass_str.parse::<i8>() {
changes.push(PropertyChange::Bass(Bass(bass.clamp(-10, 10))));
}
}
if let Some(treble_str) = &event.treble {
if let Ok(treble) = treble_str.parse::<i8>() {
changes.push(PropertyChange::Treble(Treble(treble.clamp(-10, 10))));
}
}
if let Some(loudness_str) = &event.loudness {
let loudness = loudness_str == "1" || loudness_str.eq_ignore_ascii_case("true");
changes.push(PropertyChange::Loudness(Loudness(loudness)));
}
changes
}
fn decode_av_transport(event: &AVTransportState) -> Vec<PropertyChange> {
let mut changes = vec![];
if let Some(state) = &event.transport_state {
let ps = match state.to_uppercase().as_str() {
"PLAYING" => PlaybackState::Playing,
"PAUSED_PLAYBACK" | "PAUSED" => PlaybackState::Paused,
"STOPPED" => PlaybackState::Stopped,
_ => PlaybackState::Transitioning,
};
changes.push(PropertyChange::PlaybackState(ps));
}
if event.rel_time.is_some() || event.track_duration.is_some() {
match parse_duration_ms(event.rel_time.as_deref()) {
Some(position_ms) => {
let duration_ms = parse_duration_ms(event.track_duration.as_deref()).unwrap_or(0);
changes.push(PropertyChange::Position(Position {
position_ms,
duration_ms,
}));
}
None => {
tracing::debug!(
"Skipping Position update: RelTime {:?} is missing or unparseable",
event.rel_time
);
}
}
}
if event.current_track_uri.is_some() || event.track_metadata.is_some() {
let (title, artist, album, album_art_uri) =
parse_track_metadata(event.track_metadata.as_deref());
let track = CurrentTrack {
title,
artist,
album,
album_art_uri,
uri: event.current_track_uri.clone(),
};
changes.push(PropertyChange::CurrentTrack(track));
}
changes
}
fn decode_topology(_event: &ZoneGroupTopologyState) -> Vec<PropertyChange> {
vec![]
}
fn decode_group_rendering_control(event: &GroupRenderingControlState) -> Vec<PropertyChange> {
let mut changes = vec![];
if let Some(vol) = event.group_volume {
changes.push(PropertyChange::GroupVolume(GroupVolume(vol.min(100))));
}
if let Some(muted) = event.group_mute {
changes.push(PropertyChange::GroupMute(GroupMute(muted)));
}
if let Some(changeable) = event.group_volume_changeable {
changes.push(PropertyChange::GroupVolumeChangeable(
GroupVolumeChangeable(changeable),
));
}
changes
}
pub fn decode_topology_event(event: &ZoneGroupTopologyState) -> TopologyChanges {
let mut groups = Vec::new();
let mut memberships = Vec::new();
let mut boot_seqs = Vec::new();
let mut speaker_ips = Vec::new();
let mut satellite_ids = Vec::new();
for zone_group in &event.zone_groups {
let group_id = GroupId::new(&zone_group.id);
let coordinator_id = SpeakerId::new(&zone_group.coordinator);
let member_ids: Vec<SpeakerId> = zone_group
.members
.iter()
.map(|m| SpeakerId::new(&m.uuid))
.collect();
let group_info =
GroupInfo::new(group_id.clone(), coordinator_id.clone(), member_ids.clone());
groups.push(group_info);
for member in &zone_group.members {
let speaker_id = SpeakerId::new(&member.uuid);
let is_coordinator = speaker_id == coordinator_id;
let membership = GroupMembership::new(group_id.clone(), is_coordinator);
memberships.push((speaker_id.clone(), membership));
boot_seqs.push((speaker_id.clone(), member.boot_seq));
if let Some(ip) = extract_ip_from_location(&member.location) {
speaker_ips.push((speaker_id, ip));
}
for sat in &member.satellites {
if sat.invisible == "1" {
let sat_id = SpeakerId::new(&sat.uuid);
satellite_ids.push(sat_id.clone());
if let Some(ip) = extract_ip_from_location(&sat.location) {
speaker_ips.push((sat_id, ip));
}
}
}
}
}
TopologyChanges {
groups,
memberships,
boot_seqs,
speaker_ips,
satellite_ids,
}
}
fn extract_ip_from_location(location: &str) -> Option<IpAddr> {
match Url::parse(location).ok()?.host()? {
Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
Host::Domain(_) => None,
}
}
fn parse_duration_ms(duration: Option<&str>) -> Option<u64> {
let d = duration?;
if d.is_empty() || d == "NOT_IMPLEMENTED" {
return None;
}
let parts: Vec<&str> = d.split(':').collect();
if parts.len() != 3 {
return None;
}
let hours: u64 = parts[0].parse().ok()?;
let minutes: u64 = parts[1].parse().ok()?;
let seconds_parts: Vec<&str> = parts[2].split('.').collect();
let seconds: u64 = seconds_parts[0].parse().ok()?;
let millis: u64 = seconds_parts
.get(1)
.and_then(|m| m.parse().ok())
.unwrap_or(0);
hours
.checked_mul(3600)?
.checked_add(minutes.checked_mul(60)?)?
.checked_add(seconds)?
.checked_mul(1000)?
.checked_add(millis)
}
#[derive(Debug, Deserialize)]
struct DidlItem {
#[serde(default)]
title: Option<String>,
#[serde(default)]
creator: Option<String>,
#[serde(rename = "albumArtist", default)]
album_artist: Option<String>,
#[serde(default)]
album: Option<String>,
#[serde(rename = "albumArtURI", default)]
album_art_uri: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DidlLite {
#[serde(rename = "item", default)]
items: Vec<DidlItem>,
}
pub fn parse_track_metadata(
metadata: Option<&str>,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
let xml = match metadata {
Some(m) if !m.is_empty() && m != "NOT_IMPLEMENTED" => m,
_ => return (None, None, None, None),
};
let didl = match quick_xml::de::from_str::<DidlLite>(xml) {
Ok(didl) => didl,
Err(strict_err) => {
let repaired = escape_stray_ampersands(xml);
match quick_xml::de::from_str::<DidlLite>(&repaired) {
Ok(didl) => didl,
Err(err) => {
tracing::debug!(
"Failed to parse DIDL-Lite track metadata: {strict_err}; \
retry with escaped ampersands also failed: {err}"
);
return (None, None, None, None);
}
}
}
};
let Some(item) = didl.items.into_iter().next() else {
return (None, None, None, None);
};
let artist = nonempty(item.creator).or_else(|| nonempty(item.album_artist));
(
nonempty(item.title),
artist,
nonempty(item.album),
nonempty(item.album_art_uri),
)
}
fn nonempty(value: Option<String>) -> Option<String> {
value.filter(|s| !s.is_empty())
}
fn escape_stray_ampersands(xml: &str) -> String {
let mut out = String::with_capacity(xml.len() + 16);
let mut rest = xml;
while let Some(pos) = rest.find('&') {
out.push_str(&rest[..pos]);
let tail = &rest[pos..];
match character_reference_len(tail) {
Some(len) => {
out.push_str(&tail[..len]);
rest = &tail[len..];
}
None => {
out.push_str("&");
rest = &tail[1..];
}
}
}
out.push_str(rest);
out
}
fn character_reference_len(tail: &str) -> Option<usize> {
for named in ["&", "<", ">", """, "'"] {
if tail.starts_with(named) {
return Some(named.len());
}
}
let body = tail.strip_prefix("&#")?;
let (digits, radix) = match body.strip_prefix(['x', 'X']) {
Some(hex) => (hex, 16),
None => (body, 10),
};
let end = digits.find(';')?;
let number = &digits[..end];
if number.is_empty() {
return None;
}
let code = u32::from_str_radix(number, radix).ok()?;
char::from_u32(code)?;
Some(tail.len() - digits.len() + end + 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_duration_ms() {
assert_eq!(parse_duration_ms(Some("0:00:00")), Some(0));
assert_eq!(parse_duration_ms(Some("0:01:00")), Some(60_000));
assert_eq!(parse_duration_ms(Some("1:00:00")), Some(3_600_000));
assert_eq!(parse_duration_ms(Some("0:03:45")), Some(225_000));
assert_eq!(parse_duration_ms(Some("0:03:45.500")), Some(225_500));
assert_eq!(parse_duration_ms(Some("NOT_IMPLEMENTED")), None);
assert_eq!(parse_duration_ms(None), None);
assert_eq!(parse_duration_ms(Some("")), None);
}
#[test]
fn test_parse_duration_ms_overflow_returns_none() {
assert_eq!(parse_duration_ms(Some("18446744073709551615:00:00")), None);
assert_eq!(parse_duration_ms(Some("0:0:18446744073709551615")), None);
}
#[test]
fn test_decode_av_transport_skips_position_when_rel_time_garbage() {
let event = AVTransportState {
transport_state: None,
transport_status: None,
speed: None,
current_track_uri: None,
track_duration: Some("0:03:45".to_string()),
rel_time: Some("garbage".to_string()),
abs_time: None,
rel_count: None,
abs_count: None,
play_mode: None,
track_metadata: None,
next_track_uri: None,
next_track_metadata: None,
queue_length: None,
};
let changes = decode_av_transport(&event);
assert!(
!changes
.iter()
.any(|c| matches!(c, PropertyChange::Position(_))),
"expected no Position change, got {changes:?}"
);
}
#[test]
fn test_parse_track_metadata_tolerates_bad_entities() {
let xml = r#"<DIDL-Lite><item><dc:title>Rock & Roll Star</dc:title><dc:creator>Oasis</dc:creator></item></DIDL-Lite>"#;
let (title, artist, album, art) = parse_track_metadata(Some(xml));
assert_eq!(title, Some("Rock & Roll Star".to_string()));
assert_eq!(artist, Some("Oasis".to_string()));
assert_eq!((album, art), (None, None));
assert_eq!(
parse_track_metadata(Some("<DIDL-Lite><item><dc:title>Unclosed")),
(None, None, None, None)
);
}
#[test]
fn test_parse_track_metadata_decodes_double_escaped_entity() {
let xml =
r#"<DIDL-Lite><item><dc:title>&apos;Round Midnight</dc:title></item></DIDL-Lite>"#;
let (title, ..) = parse_track_metadata(Some(xml));
assert_eq!(title, Some("'Round Midnight".to_string()));
}
#[test]
fn test_extract_ip_from_location_valid() {
let ip = extract_ip_from_location("http://192.168.4.200:1400/xml/device_description.xml");
assert_eq!(ip, Some("192.168.4.200".parse().unwrap()));
}
#[test]
fn test_extract_ip_from_location_no_path() {
let ip = extract_ip_from_location("http://10.0.0.1:1400");
assert_eq!(ip, Some("10.0.0.1".parse().unwrap()));
}
#[test]
fn test_extract_ip_from_location_missing_prefix() {
assert_eq!(extract_ip_from_location("192.168.1.1:1400/xml"), None);
}
#[test]
fn test_extract_ip_from_location_empty() {
assert_eq!(extract_ip_from_location(""), None);
}
#[test]
fn test_extract_ip_from_location_malformed() {
assert_eq!(extract_ip_from_location("http://not-an-ip:1400/xml"), None);
}
#[test]
fn test_decode_topology_extracts_ips_and_satellites() {
use sonos_stream::events::{
NetworkInfo, SatelliteInfo, ZoneGroupInfo, ZoneGroupMemberInfo, ZoneGroupTopologyState,
};
let event = ZoneGroupTopologyState {
zone_groups: vec![ZoneGroupInfo {
coordinator: "RINCON_MAIN".to_string(),
id: "RINCON_MAIN:1".to_string(),
members: vec![ZoneGroupMemberInfo {
uuid: "RINCON_MAIN".to_string(),
location: "http://192.168.4.100:1400/xml/device_description.xml".to_string(),
zone_name: "Living Room".to_string(),
software_version: "56.0".to_string(),
boot_seq: 42,
network_info: NetworkInfo::default(),
satellites: vec![SatelliteInfo {
uuid: "RINCON_SAT".to_string(),
location: "http://192.168.4.101:1400/xml/device_description.xml"
.to_string(),
zone_name: "Living Room".to_string(),
ht_sat_chan_map_set: "".to_string(),
invisible: "1".to_string(),
}],
}],
}],
vanished_devices: vec![],
};
let changes = decode_topology_event(&event);
assert_eq!(changes.speaker_ips.len(), 2);
assert_eq!(
changes.speaker_ips[0],
(
SpeakerId::new("RINCON_MAIN"),
"192.168.4.100".parse().unwrap()
)
);
assert_eq!(
changes.speaker_ips[1],
(
SpeakerId::new("RINCON_SAT"),
"192.168.4.101".parse().unwrap()
)
);
assert_eq!(changes.satellite_ids.len(), 1);
assert_eq!(changes.satellite_ids[0], SpeakerId::new("RINCON_SAT"));
}
#[test]
fn test_decode_rendering_control() {
let event = RenderingControlState {
master_volume: Some("50".to_string()),
master_mute: Some("0".to_string()),
bass: Some("5".to_string()),
treble: Some("-3".to_string()),
loudness: Some("1".to_string()),
lf_volume: None,
rf_volume: None,
lf_mute: None,
rf_mute: None,
balance: None,
other_channels: std::collections::HashMap::new(),
};
let changes = decode_rendering_control(&event);
assert_eq!(changes.len(), 5);
if let PropertyChange::Volume(v) = &changes[0] {
assert_eq!(v.0, 50);
} else {
panic!("Expected Volume change");
}
if let PropertyChange::Mute(m) = &changes[1] {
assert!(!m.0);
} else {
panic!("Expected Mute change");
}
}
#[test]
fn test_decode_av_transport() {
let event = AVTransportState {
transport_state: Some("PLAYING".to_string()),
transport_status: None,
speed: None,
current_track_uri: Some("x-sonos-spotify:track123".to_string()),
track_duration: Some("0:03:45".to_string()),
rel_time: Some("0:01:30".to_string()),
abs_time: None,
rel_count: None,
abs_count: None,
play_mode: None,
track_metadata: None,
next_track_uri: None,
next_track_metadata: None,
queue_length: None,
};
let changes = decode_av_transport(&event);
assert!(changes.len() >= 2);
if let PropertyChange::PlaybackState(ps) = &changes[0] {
assert_eq!(*ps, PlaybackState::Playing);
} else {
panic!("Expected PlaybackState change");
}
}
#[test]
fn test_decode_group_rendering_control() {
let event = GroupRenderingControlState {
group_volume: Some(42),
group_mute: Some(false),
group_volume_changeable: Some(true),
};
let changes = decode_group_rendering_control(&event);
assert_eq!(changes.len(), 3);
if let PropertyChange::GroupVolume(v) = &changes[0] {
assert_eq!(v.0, 42);
} else {
panic!("Expected GroupVolume change");
}
if let PropertyChange::GroupMute(m) = &changes[1] {
assert!(!m.0);
} else {
panic!("Expected GroupMute change");
}
if let PropertyChange::GroupVolumeChangeable(c) = &changes[2] {
assert!(c.0);
} else {
panic!("Expected GroupVolumeChangeable change");
}
}
#[test]
fn test_decode_group_rendering_control_clamps_volume() {
let event = GroupRenderingControlState {
group_volume: Some(150),
group_mute: None,
group_volume_changeable: None,
};
let changes = decode_group_rendering_control(&event);
assert_eq!(changes.len(), 1);
if let PropertyChange::GroupVolume(v) = &changes[0] {
assert_eq!(v.0, 100);
} else {
panic!("Expected GroupVolume change");
}
}
#[test]
fn test_decode_group_rendering_control_no_volume() {
let event = GroupRenderingControlState {
group_volume: None,
group_mute: Some(true),
group_volume_changeable: None,
};
let changes = decode_group_rendering_control(&event);
assert_eq!(changes.len(), 1);
if let PropertyChange::GroupMute(m) = &changes[0] {
assert!(m.0);
} else {
panic!("Expected GroupMute change");
}
}
#[test]
fn test_property_change_key() {
use crate::property::Property;
let vol_change = PropertyChange::Volume(Volume(50));
assert_eq!(vol_change.key(), Volume::KEY);
let mute_change = PropertyChange::Mute(Mute(false));
assert_eq!(mute_change.key(), Mute::KEY);
let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
assert_eq!(ps_change.key(), PlaybackState::KEY);
}
#[test]
fn test_property_change_service() {
use crate::model::GroupId;
use crate::property::SonosProperty;
let vol_change = PropertyChange::Volume(Volume(50));
assert_eq!(vol_change.service(), Volume::SERVICE);
let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
assert_eq!(ps_change.service(), PlaybackState::SERVICE);
let gm_change = PropertyChange::GroupMembership(GroupMembership::new(
GroupId::new("RINCON_test:1"),
true,
));
assert_eq!(gm_change.service(), GroupMembership::SERVICE);
}
#[test]
fn test_property_change_scope() {
use crate::property::Scope;
let vol_change = PropertyChange::Volume(Volume(50));
assert_eq!(vol_change.scope(), Scope::Speaker);
let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
assert_eq!(ps_change.scope(), Scope::Speaker);
let gv_change = PropertyChange::GroupVolume(GroupVolume(50));
assert_eq!(gv_change.scope(), Scope::Group);
let gm_change = PropertyChange::GroupMute(GroupMute(false));
assert_eq!(gm_change.scope(), Scope::Group);
}
use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
fn make_member(uuid: &str, zone_name: &str) -> ZoneGroupMemberInfo {
make_member_with_boot_seq(uuid, zone_name, 0)
}
fn make_member_with_boot_seq(
uuid: &str,
zone_name: &str,
boot_seq: u32,
) -> ZoneGroupMemberInfo {
ZoneGroupMemberInfo {
uuid: uuid.to_string(),
location: "http://192.168.1.100:1400/xml/device_description.xml".to_string(),
zone_name: zone_name.to_string(),
software_version: "79.1-56030".to_string(),
boot_seq,
network_info: NetworkInfo {
wireless_mode: "0".to_string(),
wifi_enabled: "1".to_string(),
eth_link: "1".to_string(),
channel_freq: "2412".to_string(),
behind_wifi_extender: "0".to_string(),
},
satellites: vec![],
}
}
#[test]
fn test_decode_topology_single_group_one_speaker() {
let event = ZoneGroupTopologyState {
zone_groups: vec![ZoneGroupInfo {
coordinator: "RINCON_111111111111".to_string(),
id: "RINCON_111111111111:0".to_string(),
members: vec![make_member("RINCON_111111111111", "Living Room")],
}],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert_eq!(result.groups.len(), 1);
let group = &result.groups[0];
assert_eq!(group.id.as_str(), "RINCON_111111111111:0");
assert_eq!(group.coordinator_id.as_str(), "RINCON_111111111111");
assert_eq!(group.member_ids.len(), 1);
assert!(group.is_standalone());
assert_eq!(result.memberships.len(), 1);
let (speaker_id, membership) = &result.memberships[0];
assert_eq!(speaker_id.as_str(), "RINCON_111111111111");
assert_eq!(membership.group_id.as_str(), "RINCON_111111111111:0");
assert!(membership.is_coordinator);
}
#[test]
fn test_decode_topology_single_group_multiple_speakers() {
let event = ZoneGroupTopologyState {
zone_groups: vec![ZoneGroupInfo {
coordinator: "RINCON_111111111111".to_string(),
id: "RINCON_111111111111:0".to_string(),
members: vec![
make_member("RINCON_111111111111", "Living Room"),
make_member("RINCON_222222222222", "Kitchen"),
make_member("RINCON_333333333333", "Bedroom"),
],
}],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert_eq!(result.groups.len(), 1);
let group = &result.groups[0];
assert_eq!(group.member_ids.len(), 3);
assert!(!group.is_standalone());
assert_eq!(result.memberships.len(), 3);
let coordinator_membership = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_111111111111")
.map(|(_, m)| m);
assert!(coordinator_membership.is_some());
assert!(coordinator_membership.unwrap().is_coordinator);
let kitchen_membership = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_222222222222")
.map(|(_, m)| m);
assert!(kitchen_membership.is_some());
assert!(!kitchen_membership.unwrap().is_coordinator);
let bedroom_membership = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_333333333333")
.map(|(_, m)| m);
assert!(bedroom_membership.is_some());
assert!(!bedroom_membership.unwrap().is_coordinator);
}
#[test]
fn test_decode_topology_multiple_groups() {
let event = ZoneGroupTopologyState {
zone_groups: vec![
ZoneGroupInfo {
coordinator: "RINCON_111111111111".to_string(),
id: "RINCON_111111111111:0".to_string(),
members: vec![
make_member("RINCON_111111111111", "Living Room"),
make_member("RINCON_222222222222", "Kitchen"),
],
},
ZoneGroupInfo {
coordinator: "RINCON_333333333333".to_string(),
id: "RINCON_333333333333:0".to_string(),
members: vec![make_member("RINCON_333333333333", "Bedroom")],
},
],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert_eq!(result.groups.len(), 2);
let group1 = &result.groups[0];
assert_eq!(group1.id.as_str(), "RINCON_111111111111:0");
assert_eq!(group1.member_ids.len(), 2);
let group2 = &result.groups[1];
assert_eq!(group2.id.as_str(), "RINCON_333333333333:0");
assert_eq!(group2.member_ids.len(), 1);
assert!(group2.is_standalone());
assert_eq!(result.memberships.len(), 3);
let living_room = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_111111111111")
.map(|(_, m)| m)
.unwrap();
assert_eq!(living_room.group_id.as_str(), "RINCON_111111111111:0");
assert!(living_room.is_coordinator);
let kitchen = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_222222222222")
.map(|(_, m)| m)
.unwrap();
assert_eq!(kitchen.group_id.as_str(), "RINCON_111111111111:0");
assert!(!kitchen.is_coordinator);
let bedroom = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_333333333333")
.map(|(_, m)| m)
.unwrap();
assert_eq!(bedroom.group_id.as_str(), "RINCON_333333333333:0");
assert!(bedroom.is_coordinator);
}
#[test]
fn test_decode_topology_empty_event() {
let event = ZoneGroupTopologyState {
zone_groups: vec![],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert!(result.groups.is_empty());
assert!(result.memberships.is_empty());
}
#[test]
fn test_decode_topology_extracts_boot_seq_values() {
let event = ZoneGroupTopologyState {
zone_groups: vec![ZoneGroupInfo {
coordinator: "RINCON_111111111111".to_string(),
id: "RINCON_111111111111:0".to_string(),
members: vec![
make_member_with_boot_seq("RINCON_111111111111", "Living Room", 42),
make_member_with_boot_seq("RINCON_222222222222", "Kitchen", 17),
],
}],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert_eq!(result.boot_seqs.len(), 2);
let boot_seq_111 = result
.boot_seqs
.iter()
.find(|(id, _)| id.as_str() == "RINCON_111111111111")
.map(|(_, bs)| *bs);
assert_eq!(boot_seq_111, Some(42));
let boot_seq_222 = result
.boot_seqs
.iter()
.find(|(id, _)| id.as_str() == "RINCON_222222222222")
.map(|(_, bs)| *bs);
assert_eq!(boot_seq_222, Some(17));
}
#[test]
fn test_decode_topology_boot_seq_defaults_to_zero() {
let event = ZoneGroupTopologyState {
zone_groups: vec![ZoneGroupInfo {
coordinator: "RINCON_111111111111".to_string(),
id: "RINCON_111111111111:0".to_string(),
members: vec![make_member("RINCON_111111111111", "Living Room")],
}],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert_eq!(result.boot_seqs.len(), 1);
assert_eq!(result.boot_seqs[0].1, 0);
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
fn speaker_uuid_strategy() -> impl Strategy<Value = String> {
"[A-F0-9]{12}".prop_map(|s| format!("RINCON_{s}"))
}
fn zone_group_member_strategy() -> impl Strategy<Value = ZoneGroupMemberInfo> {
(speaker_uuid_strategy(), "[A-Za-z ]{3,15}").prop_map(|(uuid, zone_name)| {
ZoneGroupMemberInfo {
uuid,
location: "http://192.168.1.100:1400/xml/device_description.xml".to_string(),
zone_name: zone_name.trim().to_string(),
software_version: "79.1-56030".to_string(),
boot_seq: 0,
network_info: NetworkInfo {
wireless_mode: "0".to_string(),
wifi_enabled: "1".to_string(),
eth_link: "1".to_string(),
channel_freq: "2412".to_string(),
behind_wifi_extender: "0".to_string(),
},
satellites: vec![],
}
})
}
fn zone_group_strategy() -> impl Strategy<Value = ZoneGroupInfo> {
proptest::collection::vec(zone_group_member_strategy(), 1..=5).prop_flat_map(|members| {
let coordinator = members[0].uuid.clone();
let group_id = format!("{coordinator}:0");
Just(ZoneGroupInfo {
coordinator,
id: group_id,
members,
})
})
}
fn topology_event_strategy() -> impl Strategy<Value = ZoneGroupTopologyState> {
proptest::collection::vec(zone_group_strategy(), 1..=3).prop_map(|zone_groups| {
ZoneGroupTopologyState {
zone_groups,
vanished_devices: vec![],
}
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_topology_event_processing_round_trip(event in topology_event_strategy()) {
let result = decode_topology_event(&event);
prop_assert_eq!(
result.groups.len(),
event.zone_groups.len(),
"Number of groups should match number of zone groups in event"
);
let total_members: usize = event.zone_groups.iter()
.map(|zg| zg.members.len())
.sum();
prop_assert_eq!(
result.memberships.len(),
total_members,
"Number of memberships should match total members across all groups"
);
for (group_info, zone_group) in result.groups.iter().zip(event.zone_groups.iter()) {
prop_assert_eq!(
group_info.id.as_str(),
&zone_group.id,
"GroupInfo ID should match zone group ID"
);
prop_assert_eq!(
group_info.coordinator_id.as_str(),
&zone_group.coordinator,
"GroupInfo coordinator should match zone group coordinator"
);
prop_assert_eq!(
group_info.member_ids.len(),
zone_group.members.len(),
"GroupInfo member count should match zone group member count"
);
}
for zone_group in &event.zone_groups {
for member in &zone_group.members {
let membership = result.memberships.iter()
.find(|(sid, _)| sid.as_str() == member.uuid)
.map(|(_, m)| m);
prop_assert!(
membership.is_some(),
"Each member should have a GroupMembership"
);
let membership = membership.unwrap();
prop_assert_eq!(
membership.group_id.as_str(),
&zone_group.id,
"GroupMembership.group_id should match zone group ID"
);
let is_coordinator = member.uuid == zone_group.coordinator;
prop_assert_eq!(
membership.is_coordinator,
is_coordinator,
"is_coordinator should be true only for the coordinator"
);
}
}
}
#[test]
fn prop_coordinator_always_in_members(event in topology_event_strategy()) {
let result = decode_topology_event(&event);
for group_info in &result.groups {
prop_assert!(
group_info.member_ids.contains(&group_info.coordinator_id),
"Coordinator should always be in member_ids"
);
}
}
#[test]
fn prop_exactly_one_coordinator_per_group(event in topology_event_strategy()) {
let result = decode_topology_event(&event);
for group_info in &result.groups {
let coordinator_count = result.memberships.iter()
.filter(|(sid, membership)| {
group_info.member_ids.contains(sid) && membership.is_coordinator
})
.count();
prop_assert_eq!(
coordinator_count,
1,
"Each group should have exactly one coordinator"
);
}
}
}
}