use serde::{Deserialize, Serialize};
use sonos_api::Service;
use crate::model::{GroupId, SpeakerInfo};
pub use state_store::Property;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Scope {
Speaker,
Group,
System,
}
pub trait SonosProperty: Property {
const SCOPE: Scope;
const SERVICE: Service;
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Volume(pub u8);
impl Property for Volume {
const KEY: &'static str = "volume";
}
impl SonosProperty for Volume {
const SCOPE: Scope = Scope::Speaker;
const SERVICE: Service = Service::RenderingControl;
}
impl Volume {
pub fn new(value: u8) -> Self {
Self(value.min(100))
}
pub fn value(&self) -> u8 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Mute(pub bool);
impl Property for Mute {
const KEY: &'static str = "mute";
}
impl SonosProperty for Mute {
const SCOPE: Scope = Scope::Speaker;
const SERVICE: Service = Service::RenderingControl;
}
impl Mute {
pub fn new(muted: bool) -> Self {
Self(muted)
}
pub fn is_muted(&self) -> bool {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Bass(pub i8);
impl Property for Bass {
const KEY: &'static str = "bass";
}
impl SonosProperty for Bass {
const SCOPE: Scope = Scope::Speaker;
const SERVICE: Service = Service::RenderingControl;
}
impl Bass {
pub fn new(value: i8) -> Self {
Self(value.clamp(-10, 10))
}
pub fn value(&self) -> i8 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Treble(pub i8);
impl Property for Treble {
const KEY: &'static str = "treble";
}
impl SonosProperty for Treble {
const SCOPE: Scope = Scope::Speaker;
const SERVICE: Service = Service::RenderingControl;
}
impl Treble {
pub fn new(value: i8) -> Self {
Self(value.clamp(-10, 10))
}
pub fn value(&self) -> i8 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Loudness(pub bool);
impl Property for Loudness {
const KEY: &'static str = "loudness";
}
impl SonosProperty for Loudness {
const SCOPE: Scope = Scope::Speaker;
const SERVICE: Service = Service::RenderingControl;
}
impl Loudness {
pub fn new(enabled: bool) -> Self {
Self(enabled)
}
pub fn is_enabled(&self) -> bool {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GroupVolume(pub u16);
impl Property for GroupVolume {
const KEY: &'static str = "group_volume";
}
impl SonosProperty for GroupVolume {
const SCOPE: Scope = Scope::Group;
const SERVICE: Service = Service::GroupRenderingControl;
}
impl GroupVolume {
pub fn new(value: u16) -> Self {
Self(value.min(100))
}
pub fn value(&self) -> u16 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GroupMute(pub bool);
impl Property for GroupMute {
const KEY: &'static str = "group_mute";
}
impl SonosProperty for GroupMute {
const SCOPE: Scope = Scope::Group;
const SERVICE: Service = Service::GroupRenderingControl;
}
impl GroupMute {
pub fn new(muted: bool) -> Self {
Self(muted)
}
pub fn is_muted(&self) -> bool {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GroupVolumeChangeable(pub bool);
impl Property for GroupVolumeChangeable {
const KEY: &'static str = "group_volume_changeable";
}
impl SonosProperty for GroupVolumeChangeable {
const SCOPE: Scope = Scope::Group;
const SERVICE: Service = Service::GroupRenderingControl;
}
impl GroupVolumeChangeable {
pub fn new(changeable: bool) -> Self {
Self(changeable)
}
pub fn is_changeable(&self) -> bool {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum PlaybackState {
Playing,
Paused,
Stopped,
Transitioning,
}
impl Property for PlaybackState {
const KEY: &'static str = "playback_state";
}
impl SonosProperty for PlaybackState {
const SCOPE: Scope = Scope::Speaker;
const SERVICE: Service = Service::AVTransport;
}
impl PlaybackState {
pub fn from_transport_state(state: &str) -> Self {
match state.to_uppercase().as_str() {
"PLAYING" => PlaybackState::Playing,
"PAUSED_PLAYBACK" | "PAUSED" => PlaybackState::Paused,
"STOPPED" => PlaybackState::Stopped,
"TRANSITIONING" => PlaybackState::Transitioning,
_ => PlaybackState::Stopped,
}
}
pub fn is_playing(&self) -> bool {
matches!(self, PlaybackState::Playing)
}
pub fn is_paused(&self) -> bool {
matches!(self, PlaybackState::Paused)
}
pub fn is_stopped(&self) -> bool {
matches!(self, PlaybackState::Stopped)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Position {
pub position_ms: u64,
pub duration_ms: u64,
}
impl Property for Position {
const KEY: &'static str = "position";
}
impl SonosProperty for Position {
const SCOPE: Scope = Scope::Speaker;
const SERVICE: Service = Service::AVTransport;
}
impl Position {
pub fn new(position_ms: u64, duration_ms: u64) -> Self {
Self {
position_ms,
duration_ms,
}
}
pub fn progress(&self) -> f64 {
if self.duration_ms == 0 {
0.0
} else {
(self.position_ms as f64) / (self.duration_ms as f64)
}
}
pub fn parse_time_to_ms(time_str: &str) -> Option<u64> {
if !time_str.contains(':') {
return None;
}
let parts: Vec<&str> = time_str.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);
Some((hours * 3600 + minutes * 60 + seconds) * 1000 + millis)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CurrentTrack {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub album_art_uri: Option<String>,
pub uri: Option<String>,
}
impl Property for CurrentTrack {
const KEY: &'static str = "current_track";
}
impl SonosProperty for CurrentTrack {
const SCOPE: Scope = Scope::Speaker;
const SERVICE: Service = Service::AVTransport;
}
impl CurrentTrack {
pub fn new() -> Self {
Self {
title: None,
artist: None,
album: None,
album_art_uri: None,
uri: None,
}
}
pub fn is_empty(&self) -> bool {
self.title.is_none() && self.artist.is_none() && self.uri.is_none()
}
pub fn display(&self) -> String {
match (&self.artist, &self.title) {
(Some(artist), Some(title)) => format!("{artist} - {title}"),
(None, Some(title)) => title.clone(),
(Some(artist), None) => artist.clone(),
(None, None) => "Unknown".to_string(),
}
}
}
impl Default for CurrentTrack {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GroupMembership {
pub group_id: GroupId,
pub is_coordinator: bool,
}
impl Property for GroupMembership {
const KEY: &'static str = "group_membership";
}
impl SonosProperty for GroupMembership {
const SCOPE: Scope = Scope::Speaker;
const SERVICE: Service = Service::ZoneGroupTopology;
}
impl GroupMembership {
pub fn new(group_id: GroupId, is_coordinator: bool) -> Self {
Self {
group_id,
is_coordinator,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Topology {
pub speakers: Vec<SpeakerInfo>,
pub groups: Vec<GroupInfo>,
}
impl Property for Topology {
const KEY: &'static str = "topology";
}
impl SonosProperty for Topology {
const SCOPE: Scope = Scope::System;
const SERVICE: Service = Service::ZoneGroupTopology;
}
impl Topology {
pub fn new(speakers: Vec<SpeakerInfo>, groups: Vec<GroupInfo>) -> Self {
Self { speakers, groups }
}
pub fn empty() -> Self {
Self {
speakers: vec![],
groups: vec![],
}
}
pub fn speaker_count(&self) -> usize {
self.speakers.len()
}
pub fn group_count(&self) -> usize {
self.groups.len()
}
}
impl Default for Topology {
fn default() -> Self {
Self::empty()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GroupInfo {
pub id: GroupId,
pub coordinator_id: crate::model::SpeakerId,
pub member_ids: Vec<crate::model::SpeakerId>,
}
impl GroupInfo {
pub fn new(
id: GroupId,
coordinator_id: crate::model::SpeakerId,
member_ids: Vec<crate::model::SpeakerId>,
) -> Self {
Self {
id,
coordinator_id,
member_ids,
}
}
pub fn is_standalone(&self) -> bool {
self.member_ids.len() == 1
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_volume_clamping() {
assert_eq!(Volume::new(50).value(), 50);
assert_eq!(Volume::new(150).value(), 100);
assert_eq!(Volume::new(0).value(), 0);
}
#[test]
fn test_bass_clamping() {
assert_eq!(Bass::new(0).value(), 0);
assert_eq!(Bass::new(-15).value(), -10);
assert_eq!(Bass::new(15).value(), 10);
}
#[test]
fn test_playback_state_parsing() {
assert_eq!(
PlaybackState::from_transport_state("PLAYING"),
PlaybackState::Playing
);
assert_eq!(
PlaybackState::from_transport_state("PAUSED_PLAYBACK"),
PlaybackState::Paused
);
assert_eq!(
PlaybackState::from_transport_state("STOPPED"),
PlaybackState::Stopped
);
assert_eq!(
PlaybackState::from_transport_state("unknown"),
PlaybackState::Stopped
);
}
#[test]
fn test_position_progress() {
let pos = Position::new(30_000, 180_000); assert!((pos.progress() - 0.1667).abs() < 0.001);
let zero_duration = Position::new(1000, 0);
assert_eq!(zero_duration.progress(), 0.0);
}
#[test]
fn test_position_time_parsing() {
assert_eq!(Position::parse_time_to_ms("0:00:00"), Some(0));
assert_eq!(Position::parse_time_to_ms("0:01:00"), Some(60_000));
assert_eq!(Position::parse_time_to_ms("1:00:00"), Some(3_600_000));
assert_eq!(Position::parse_time_to_ms("0:03:45"), Some(225_000));
assert_eq!(Position::parse_time_to_ms("0:03:45.500"), Some(225_500));
assert_eq!(Position::parse_time_to_ms("NOT_IMPLEMENTED"), None);
}
#[test]
fn test_current_track_display() {
let track = CurrentTrack {
title: Some("Song".to_string()),
artist: Some("Artist".to_string()),
album: None,
album_art_uri: None,
uri: None,
};
assert_eq!(track.display(), "Artist - Song");
let title_only = CurrentTrack {
title: Some("Song".to_string()),
artist: None,
album: None,
album_art_uri: None,
uri: None,
};
assert_eq!(title_only.display(), "Song");
}
#[test]
fn test_property_constants() {
assert_eq!(Volume::KEY, "volume");
assert_eq!(<Volume as SonosProperty>::SCOPE, Scope::Speaker);
assert_eq!(Topology::KEY, "topology");
assert_eq!(<Topology as SonosProperty>::SCOPE, Scope::System);
}
#[test]
fn test_group_volume_clamping() {
assert_eq!(GroupVolume::new(50).value(), 50);
assert_eq!(GroupVolume::new(200).value(), 100);
assert_eq!(GroupVolume::new(0).value(), 0);
assert_eq!(GroupVolume::new(100).value(), 100);
}
#[test]
fn test_group_volume_property_metadata() {
assert_eq!(GroupVolume::KEY, "group_volume");
assert_eq!(<GroupVolume as SonosProperty>::SCOPE, Scope::Group);
assert_eq!(
<GroupVolume as SonosProperty>::SERVICE,
Service::GroupRenderingControl
);
}
#[test]
fn test_group_membership_always_has_valid_group_id() {
let group_id = GroupId::new("RINCON_12345:1");
let membership = GroupMembership::new(group_id.clone(), true);
assert_eq!(membership.group_id, group_id);
assert!(!membership.group_id.as_str().is_empty());
}
#[test]
fn test_group_membership_is_coordinator_flag() {
let group_id = GroupId::new("RINCON_12345:1");
let coordinator = GroupMembership::new(group_id.clone(), true);
assert!(coordinator.is_coordinator);
let member = GroupMembership::new(group_id.clone(), false);
assert!(!member.is_coordinator);
}
#[test]
fn test_group_membership_equality() {
let group_id = GroupId::new("RINCON_12345:1");
let membership1 = GroupMembership::new(group_id.clone(), true);
let membership2 = GroupMembership::new(group_id.clone(), true);
let membership3 = GroupMembership::new(group_id.clone(), false);
let membership4 = GroupMembership::new(GroupId::new("RINCON_67890:1"), true);
assert_eq!(membership1, membership2);
assert_ne!(membership1, membership3);
assert_ne!(membership1, membership4);
}
#[test]
fn test_group_membership_property_metadata() {
assert_eq!(GroupMembership::KEY, "group_membership");
assert_eq!(<GroupMembership as SonosProperty>::SCOPE, Scope::Speaker);
assert_eq!(
<GroupMembership as SonosProperty>::SERVICE,
Service::ZoneGroupTopology
);
}
#[test]
fn test_group_mute_property_metadata() {
assert_eq!(GroupMute::KEY, "group_mute");
assert_eq!(<GroupMute as SonosProperty>::SCOPE, Scope::Group);
assert_eq!(
<GroupMute as SonosProperty>::SERVICE,
Service::GroupRenderingControl
);
}
#[test]
fn test_group_volume_changeable_property_metadata() {
assert_eq!(GroupVolumeChangeable::KEY, "group_volume_changeable");
assert_eq!(
<GroupVolumeChangeable as SonosProperty>::SCOPE,
Scope::Group
);
assert_eq!(
<GroupVolumeChangeable as SonosProperty>::SERVICE,
Service::GroupRenderingControl
);
}
}