From f096a8cd8f1c67d5288dddd44a7f77fa905ab8f3 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 30 Nov 2025 10:54:01 +0100 Subject: [PATCH] Couche d'abstraction supplementaire --- .gitignore | 1 + Cargo.lock | 1 + pmocontrol/Cargo.toml | 1 + pmocontrol/examples/event_demo.rs | 197 +++++++ pmocontrol/examples/renderer_demo.rs | 500 +++++++++++------- pmocontrol/src/avtransport_client.rs | 67 +++ pmocontrol/src/capabilities.rs | 101 ++++ pmocontrol/src/control_point.rs | 259 ++++++++- pmocontrol/src/events.rs | 33 ++ pmocontrol/src/lib.rs | 14 +- pmocontrol/src/model.rs | 22 + pmocontrol/src/music_renderer.rs | 162 ++++++ .../src/{renderer.rs => upnp_renderer.rs} | 87 ++- 13 files changed, 1235 insertions(+), 210 deletions(-) create mode 100644 pmocontrol/examples/event_demo.rs create mode 100644 pmocontrol/src/capabilities.rs create mode 100644 pmocontrol/src/events.rs create mode 100644 pmocontrol/src/music_renderer.rs rename pmocontrol/src/{renderer.rs => upnp_renderer.rs} (70%) diff --git a/.gitignore b/.gitignore index b278afdc..0bef2d67 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ test_upnp*.cargo/ setup-env.sh cache gupnp-tools +pmocontrol_[0_9]*.txt \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index e01f7c48..691963c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3163,6 +3163,7 @@ name = "pmocontrol" version = "0.1.0" dependencies = [ "anyhow", + "crossbeam-channel", "pmoupnp", "quick-xml 0.38.4", "thiserror 2.0.17", diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index 22611edd..2416a266 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -12,3 +12,4 @@ tracing = "0.1.41" tracing-subscriber = "0.3" anyhow = "1.0" xmltree = "0.11.0" +crossbeam-channel = "0.5" diff --git a/pmocontrol/examples/event_demo.rs b/pmocontrol/examples/event_demo.rs new file mode 100644 index 00000000..70600dc0 --- /dev/null +++ b/pmocontrol/examples/event_demo.rs @@ -0,0 +1,197 @@ +// examples/events_demo.rs +// +// Demo temps réel des RendererEvent émis par le runtime de ControlPoint : +// - SSDP discovery via `ControlPoint` +// - sélection d'un renderer (facultatif) +// - abonnement à `subscribe_events()` +// - affichage continu des événements avec horodatage HH:MM:SS +// +// Build et run (depuis la racine du crate pmocontrol) : +// cargo run --example events_demo -- # écoute tous les renderers +// cargo run --example events_demo -- 0 # filtre sur renderer index 0 +// cargo run --example events_demo -- 1 # filtre sur renderer index 1, etc. +// +// Ctrl-C pour quitter. + +use std::env; +use std::io; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use pmocontrol::{ + ControlPoint, DeviceRegistryRead, PlaybackPositionInfo, PlaybackState, RendererEvent, + RendererId, RendererInfo, +}; + +fn main() -> io::Result<()> { + // Logging simple (tracing_subscriber est déjà utilisé dans les autres exemples) + let _ = tracing_subscriber::fmt::try_init(); + println!("Starting PMOMusic renderer events demo..."); + + // 1. Lance le ControlPoint (timeout HTTP pour les descriptions UPnP) + let cp = ControlPoint::spawn(5)?; + + // 2. Laisse la découverte tourner un peu avant de lister les renderers + println!("Waiting 5 seconds for SSDP discovery..."); + thread::sleep(Duration::from_secs(5)); + + let registry = cp.registry(); + let renderers: Vec = { + let reg = registry.read().unwrap(); + reg.list_renderers() + }; + + if renderers.is_empty() { + println!("No renderers discovered. Make sure your devices are on and reachable."); + return Ok(()); + } + + println!("\nDiscovered renderers:"); + for (idx, info) in renderers.iter().enumerate() { + println!( + " [{}] {} | model={} | udn={} | location={} | online={}", + idx, info.friendly_name, info.model_name, info.udn, info.location, info.online + ); + } + + // 3. Optionnel : sélection d'un renderer par index (filtrage des événements) + let args: Vec = env::args().collect(); + let selected_id: Option = if args.len() >= 2 { + match args[1].parse::() { + Ok(idx) if idx < renderers.len() => { + let info = &renderers[idx]; + println!( + "\nFiltering events on renderer [{}] {} (id={})", + idx, info.friendly_name, info.id.0 + ); + Some(info.id.clone()) + } + Ok(idx) => { + eprintln!( + "\nRenderer index {} is out of range (0..{}), listening to all renderers.", + idx, + renderers.len().saturating_sub(1) + ); + None + } + Err(e) => { + eprintln!( + "\nArgument '{}' is not a valid index (error: {}), listening to all renderers.", + args[1], e + ); + None + } + } + } else { + println!("\nNo renderer index provided, listening to events from all renderers."); + None + }; + + // 4. Abonnement aux événements du runtime + let rx = cp.subscribe_events(); + + println!("\nSubscribed to renderer events."); + println!("Press Ctrl-C to quit.\n"); + + // 5. Boucle bloquante sur les événements + loop { + match rx.recv() { + Ok(event) => { + if let Some(ref id) = selected_id { + // Filtre : on ignore les événements des autres renderers + if !event_matches_id(&event, id) { + continue; + } + } + + print_event(&event); + } + Err(err) => { + eprintln!("Event channel closed: {}. Exiting.", err); + break; + } + } + } + + Ok(()) +} + +/// Vérifie si un événement concerne un RendererId donné. +fn event_matches_id(event: &RendererEvent, id: &RendererId) -> bool { + match event { + RendererEvent::StateChanged { id: eid, .. } => eid == id, + RendererEvent::PositionChanged { id: eid, .. } => eid == id, + RendererEvent::VolumeChanged { id: eid, .. } => eid == id, + RendererEvent::MuteChanged { id: eid, .. } => eid == id, + } +} + +/// Format HH:MM:SS basé sur l'heure système (UTC mod 24h). +fn now_hms() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)); + let total = now.as_secs() % 86_400; + let h = total / 3600; + let m = (total % 3600) / 60; + let s = total % 60; + format!("{:02}:{:02}:{:02}", h, m, s) +} + +/// Affichage lisible d'un PlaybackState. +fn format_playback_state(state: &PlaybackState) -> String { + match state { + PlaybackState::Stopped => "Stopped".to_string(), + PlaybackState::Playing => "Playing".to_string(), + PlaybackState::Paused => "Paused".to_string(), + PlaybackState::Transitioning => "Transitioning".to_string(), + PlaybackState::NoMedia => "NoMedia".to_string(), + PlaybackState::Unknown(s) => format!("Unknown({})", s), + } +} + +/// Affichage lisible d'un PlaybackPositionInfo. +fn format_position(pos: &PlaybackPositionInfo) -> String { + let track = pos.track.map(|t| t.to_string()).unwrap_or_else(|| "-".to_string()); + let rel = pos + .rel_time + .as_deref() + .unwrap_or("-") + .to_string(); + let dur = pos + .track_duration + .as_deref() + .unwrap_or("-") + .to_string(); + + format!("track={} rel_time={} duration={}", track, rel, dur) +} + +/// Affiche un RendererEvent avec horodatage. +fn print_event(event: &RendererEvent) { + let ts = now_hms(); + match event { + RendererEvent::StateChanged { id, state } => { + println!( + "[{}] [{}] StateChanged: {}", + ts, + id.0, + format_playback_state(state) + ); + } + RendererEvent::PositionChanged { id, position } => { + println!( + "[{}] [{}] PositionChanged: {}", + ts, + id.0, + format_position(position) + ); + } + RendererEvent::VolumeChanged { id, volume } => { + println!("[{}] [{}] VolumeChanged: {}", ts, id.0, volume); + } + RendererEvent::MuteChanged { id, mute } => { + println!("[{}] [{}] MuteChanged: {}", ts, id.0, mute); + } + } +} diff --git a/pmocontrol/examples/renderer_demo.rs b/pmocontrol/examples/renderer_demo.rs index 5c05d363..93ad44ea 100644 --- a/pmocontrol/examples/renderer_demo.rs +++ b/pmocontrol/examples/renderer_demo.rs @@ -1,229 +1,345 @@ -// pmocontrol/examples/renderer_demo.rs +// examples/music_renderer_demo.rs +// +// End-to-end demo using the `MusicRenderer` façade: +// - SSDP discovery via `ControlPoint` +// - selection of a renderer +// - generic `TransportControl` + `VolumeControl` + `PlaybackStatus` + `PlaybackPosition` +// - optional UPnP-specific inspection (TransportInfo, ConnectionManager) +// +// Build and run (from pmocontrol crate root): +// cargo run --example music_renderer_demo -- [index] [uri] +// +// index: optional 0-based renderer index (default: 0) +// uri : optional URI to play (default: Radio Paradise FLAC) -use anyhow::{anyhow, Result}; -use pmocontrol::{ControlPoint, Renderer}; +use anyhow::Result; +use pmocontrol::PlaybackPosition; +use pmocontrol::{ + ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities, + RendererProtocol, TransportControl, VolumeControl, +}; use std::env; -use std::io::{self, Write}; use std::thread; use std::time::Duration; +// Default URI if none is provided on the CLI. +const DEFAULT_TEST_URI: &str = + "https://audio-fb.radioparadise.com/chan/1/x/1117/4/g/1117-3.flac"; + +// Extra wait after play_uri() so slow renderers (e.g. Arylic H50) have time +// to prefetch and actually start playback. +const AFTER_PLAY_WAIT_SECS: u64 = 15; + fn main() -> Result<()> { - // ------------------------------------------------------------------------- - // CLI arguments - // - // Usage: - // renderer_demo - // -> URI par défaut, renderer index 0 - // - // renderer_demo 1 - // -> URI par défaut, renderer index 1 - // - // renderer_demo https://.../track.flac - // -> cette URI, renderer index 0 - // - // renderer_demo https://.../track.flac 1 - // -> cette URI, renderer index 1 - // ------------------------------------------------------------------------- - let default_uri = - "https://audio-fb.radioparadise.com/chan/1/x/1117/4/g/1117-3.flac".to_string(); - let mut uri = default_uri.clone(); - let mut renderer_index: usize = 0; - - let args: Vec = env::args().collect(); - if args.len() >= 2 { - let first = &args[1]; - if let Ok(idx) = first.parse::() { - // cas: renderer_demo 1 [URI] - renderer_index = idx; - if args.len() >= 3 { - uri = args[2].clone(); - } - } else { - // cas: renderer_demo URI [INDEX] - uri = first.clone(); - if args.len() >= 3 { - if let Ok(idx) = args[2].parse::() { - renderer_index = idx; - } - } - } - } - - println!("Using URI : {}", uri); - println!("Requested renderer index : {}", renderer_index); - - // ------------------------------------------------------------------------- - // 1. Démarrer le control point et laisser la découverte tourner un peu - // ------------------------------------------------------------------------- + // 1. Start control point and let discovery run a bit let cp = ControlPoint::spawn(5)?; thread::sleep(Duration::from_secs(5)); - // ------------------------------------------------------------------------- - // 2. Récupérer la liste des renderers (handles haut niveau) - // ------------------------------------------------------------------------- - let mut renderers: Vec = cp.list_renderer_handles(); + // 2. Snapshot of logical music renderers + let mut renderers: Vec = cp.list_music_renderers(); - // Filtre : on ignore le renderer PMOMusic interne en développement + // Filter out the in-dev PMOMusic renderer (if present) renderers.retain(|r| { - !r.friendly_name() - .to_ascii_lowercase() - .contains("pmomusic audio renderer") + let name = r.friendly_name().to_ascii_lowercase(); + !name.contains("pmomusic audio renderer") }); if renderers.is_empty() { - println!("No valid UPnP MediaRenderer discovered."); + println!("No valid music renderer discovered."); return Ok(()); } - println!("\nDiscovered MediaRenderers:"); - for (idx, r) in renderers.iter().enumerate() { + // 3. CLI args: [index] [uri] + let args: Vec = env::args().skip(1).collect(); + + let selected_index: usize = if !args.is_empty() { + args[0].parse().unwrap_or(0) + } else { + 0 + }; + + let maybe_uri: Option = if args.len() >= 2 { + Some(args[1].clone()) + } else { + None + }; + + // Bounds check + if selected_index >= renderers.len() { println!( - " [{}] {} | model={} | manufacturer={} | has_avt={} | has_rc={}", - idx, - r.friendly_name(), - r.info.model_name, - r.info.manufacturer, - r.has_avtransport(), - r.has_rendering_control(), + "Renderer index {} out of range ({} available).", + selected_index, + renderers.len() + ); + return Ok(()); + } + + // 4. List renderers with basic info + println!("Discovered MusicRenderers:"); + for (idx, r) in renderers.iter().enumerate() { + let info = r.info(); + println!( + " [{}] {} | model={} | udn={} | location={}", + idx, info.friendly_name, info.model_name, info.udn, info.location + ); + print_capabilities(" ", &info.capabilities, &info.protocol); + } + + // 5. Select renderer + let renderer = &renderers[selected_index]; + let info = renderer.info(); + + println!("\nSelected renderer (index {}):", selected_index); + println!(" Name : {}", info.friendly_name); + println!(" Model : {}", info.model_name); + println!(" Manufacturer: {}", info.manufacturer); + println!(" UDN : {}", info.udn); + println!(" Location : {}", info.location); + println!(" Protocol : {:?}", info.protocol); + print_capabilities(" ", &info.capabilities, &info.protocol); + + if let Some(upnp) = renderer.as_upnp() { + println!( + " [UPnP] AVTransport control URL : {}", + upnp + .info + .avtransport_control_url + .as_deref() + .unwrap_or("") + ); + println!( + " [UPnP] AVTransport service type: {}", + upnp + .info + .avtransport_service_type + .as_deref() + .unwrap_or("") + ); + println!( + " [UPnP] RenderingControl control URL : {}", + upnp + .info + .rendering_control_control_url + .as_deref() + .unwrap_or("") + ); + println!( + " [UPnP] RenderingControl service type: {}", + upnp + .info + .rendering_control_service_type + .as_deref() + .unwrap_or("") + ); + println!( + " [UPnP] ConnectionManager control URL : {}", + upnp + .info + .connection_manager_control_url + .as_deref() + .unwrap_or("") + ); + println!( + " [UPnP] ConnectionManager service type: {}", + upnp + .info + .connection_manager_service_type + .as_deref() + .unwrap_or("") ); } - if renderer_index >= renderers.len() { - return Err(anyhow!( - "Renderer index {} out of range (0..={})", - renderer_index, - renderers.len().saturating_sub(1) - )); - } + // 6. Initial state dump (generic + UPnP-specific) + dump_renderer_state(renderer, "Initial state")?; - // ------------------------------------------------------------------------- - // 3. Sélection du renderer - // ------------------------------------------------------------------------- - let renderer = &renderers[renderer_index]; + // 7. Play URI via logical façade + let uri = maybe_uri.unwrap_or_else(|| DEFAULT_TEST_URI.to_string()); + let meta = ""; // or full DIDL-Lite - println!("\nSelected renderer (index {}):", renderer_index); - println!(" Name : {}", renderer.friendly_name()); - println!(" Model : {}", renderer.info.model_name); - println!(" Manufacturer: {}", renderer.info.manufacturer); - println!(" UDN : {}", renderer.info.udn); - println!(" Location : {}", renderer.info.location); - println!(" has_avt : {}", renderer.has_avtransport()); - println!(" has_rc : {}", renderer.has_rendering_control()); - - // ------------------------------------------------------------------------- - // 4. Si RenderingControl dispo : tester get/set volume + mute - // (set_* remet juste la même valeur pour ne rien changer en pratique) - // ------------------------------------------------------------------------- - if renderer.has_rendering_control() { - println!("\n[RenderingControl]"); - match renderer.get_master_volume() { - Ok(vol) => { - println!(" Current master volume: {}", vol); - if let Err(e) = renderer.set_master_volume(vol) { - println!(" set_master_volume({}) failed: {}", vol, e); - } else { - println!(" set_master_volume({}) OK (no-op)", vol); - } - } - Err(e) => { - println!(" get_master_volume() failed: {}", e); - } - } - - match renderer.get_master_mute() { - Ok(muted) => { - println!(" Current master mute : {}", muted); - if let Err(e) = renderer.set_master_mute(muted) { - println!(" set_master_mute({}) failed: {}", muted, e); - } else { - println!(" set_master_mute({}) OK (no-op)", muted); - } - } - Err(e) => { - println!(" get_master_mute() failed: {}", e); - } - } + println!("\nCalling play_uri on music renderer..."); + if let Err(e) = renderer.play_uri(&uri, meta) { + println!(" play_uri failed: {e}"); } else { - println!("\n[RenderingControl]"); - println!(" Renderer has no RenderingControl service."); + println!(" play_uri: OK"); } - // ------------------------------------------------------------------------- - // 5. Si AVTransport dispo : tester play_uri / seek / pause / stop - // ------------------------------------------------------------------------- - if !renderer.has_avtransport() { - println!("\n[AVTransport]"); - println!(" Renderer has no AVTransport service, skipping playback tests."); - return Ok(()); + println!( + "Waiting {}s to let the renderer prefetch and start playback...", + AFTER_PLAY_WAIT_SECS + ); + thread::sleep(Duration::from_secs(AFTER_PLAY_WAIT_SECS)); + dump_renderer_state(renderer, "After play_uri")?; + + // 8. Short progress polling using the PlaybackPosition façade + progress_monitor(renderer, "Progress while playing", 8, 3); + + // 9. Seek (if supported) + println!("\nCalling seek_rel_time(\"00:01:00\")..."); + if let Err(e) = renderer.seek_rel_time("00:01:00") { + println!(" seek_rel_time failed: {e}"); + } else { + println!(" seek_rel_time: OK"); + thread::sleep(Duration::from_secs(2)); + dump_renderer_state(renderer, "After seek_rel_time")?; + + // 9b. Second progress loop after seek: verify RelTime advances + progress_monitor(renderer, "Progress after seek", 8, 3); } - println!("\n[AVTransport]"); - - // Helper pour afficher l'état courant - let dump_info = |label: &str| -> Result<()> { - let info = renderer - .avtransport() - .and_then(|_| renderer.avtransport().unwrap().get_transport_info(0))?; - println!("\n [{}]", label); - println!(" State : {}", info.current_transport_state); - println!(" Status : {}", info.current_transport_status); - println!(" Speed : {}", info.current_speed); - Ok(()) - }; - - // Set + Play - println!("\n Calling play_uri(...)"); - renderer.play_uri(&uri, "")?; - println!(" play_uri: OK"); - thread::sleep(Duration::from_secs(8)); - let _ = dump_info("After play_uri"); - - // Seek (si supporté) - println!("\n Calling seek_rel_time(\"00:01:00\")..."); - match renderer.seek_rel_time("00:01:00") { - Ok(()) => { - println!(" seek_rel_time: OK"); - thread::sleep(Duration::from_secs(3)); - let _ = dump_info("After seek_rel_time"); - } - Err(e) => { - println!(" seek_rel_time failed: {}", e); - } + // 10. Volume dance (if supported) + println!("\nProbing volume control via music façade..."); + if let Err(e) = volume_demo(renderer) { + println!(" Volume control not fully usable: {e}"); } - // Pause (ENTER pour laisser jouer) - print!("\nPress ENTER to Pause..."); - io::stdout().flush().ok(); - let _ = io::stdin().read_line(&mut String::new()); - - println!("\n Calling pause()..."); - match renderer.pause() { - Ok(()) => { - println!(" pause: OK"); - thread::sleep(Duration::from_secs(2)); - let _ = dump_info("After pause"); - } - Err(e) => { - println!(" pause failed: {}", e); - } + // 11. Pause then Stop + println!("\nCalling pause() via music façade..."); + if let Err(e) = renderer.pause() { + println!(" pause failed: {e}"); + } else { + println!(" pause: OK"); + thread::sleep(Duration::from_secs(2)); + dump_renderer_state(renderer, "After pause")?; } - // Stop - print!("\nPress ENTER to Stop..."); - io::stdout().flush().ok(); - let _ = io::stdin().read_line(&mut String::new()); - - println!("\n Calling stop()..."); - match renderer.stop() { - Ok(()) => { - println!(" stop: OK"); - let _ = dump_info("After stop"); - } - Err(e) => { - println!(" stop failed: {}", e); - } + println!("\nCalling stop() via music façade..."); + if let Err(e) = renderer.stop() { + println!(" stop failed: {e}"); + } else { + println!(" stop: OK"); + dump_renderer_state(renderer, "After stop")?; } println!("\nDone."); Ok(()) } + +fn print_capabilities(prefix: &str, caps: &RendererCapabilities, proto: &RendererProtocol) { + println!("{prefix}Capabilities:"); + println!("{prefix} Protocol : {:?}", proto); + println!("{prefix} AVTransport : {}", caps.has_avtransport); + println!("{prefix} RendControl : {}", caps.has_rendering_control); + println!("{prefix} ConnManager : {}", caps.has_connection_manager); + println!("{prefix} OH Playlist : {}", caps.has_oh_playlist); + println!("{prefix} OH Volume : {}", caps.has_oh_volume); + println!("{prefix} OH Info : {}", caps.has_oh_info); + println!("{prefix} OH Time : {}", caps.has_oh_time); + println!("{prefix} OH Radio : {}", caps.has_oh_radio); +} + +fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> { + println!("\n[{label}]"); + + if let Ok(state) = renderer.playback_state() { + println!(" Playback state (music): {:?}", state); + } else { + println!(" Playback state (music): "); + } + + // Generic volume façade + match renderer.volume() { + Ok(v) => println!(" Volume (music) : {}", v), + Err(e) => println!(" Volume not available: {e}"), + } + + match renderer.mute() { + Ok(m) => println!(" Mute : {}", m), + Err(e) => println!(" Mute state unknown : {e}"), + } + + if let Ok(pos) = renderer.playback_position() { + println!(" Position info:"); + println!(" Track : {:?}", pos.track); + println!(" Duration : {:?}", pos.track_duration); + println!(" RelTime : {:?}", pos.rel_time); + println!(" AbsTime : {:?}", pos.abs_time); + } else { + println!(" Position info: "); + } + + // Optional UPnP-specific TransportInfo + if let Some(upnp) = renderer.as_upnp() { + if upnp.has_avtransport() { + match upnp.avtransport() { + Ok(avt) => match avt.get_transport_info(0) { + Ok(info) => { + println!(" [UPnP] TransportInfo:"); + println!(" State : {}", info.current_transport_state); + println!(" Status : {}", info.current_transport_status); + println!(" Speed : {}", info.current_speed); + } + Err(e) => { + println!(" [UPnP] TransportInfo unavailable: {e}"); + } + }, + Err(e) => println!(" [UPnP] No AVTransport client: {e}"), + } + } else { + println!(" [UPnP] AVTransport not present on this renderer."); + } + } + + Ok(()) +} + +fn progress_monitor( + renderer: &MusicRenderer, + label: &str, + iterations: usize, + interval_secs: u64, +) { + println!( + "\n[{label}] polling playback state/position {} times (every {} s)...", + iterations, interval_secs + ); + + for i in 0..iterations { + if let Ok(state) = renderer.playback_state() { + print!(" Sample {:02}: state={:?}", i + 1, state); + } else { + print!(" Sample {:02}: state=", i + 1); + } + + if let Ok(pos) = renderer.playback_position() { + println!( + " | track={:?}, rel={:?}, dur={:?}", + pos.track, pos.rel_time, pos.track_duration + ); + } else { + println!(" | position="); + } + + thread::sleep(Duration::from_secs(interval_secs)); + } +} + +fn volume_demo(renderer: &MusicRenderer) -> Result<()> { + // Try to get current volume + let original = renderer.volume()?; + println!(" Current music volume : {}", original); + + // Try mute toggle + let muted = renderer.mute()?; + println!(" Current mute state : {}", muted); + + println!(" Setting mute = true..."); + renderer.set_mute(true)?; + thread::sleep(Duration::from_secs(1)); + println!(" Mute now: {}", renderer.mute()?); + + println!(" Restoring mute = {}", muted); + renderer.set_mute(muted)?; + thread::sleep(Duration::from_millis(500)); + + // Small volume bump if possible + let new_volume = original.saturating_add(5).min(u16::MAX); + println!(" Bumping volume to : {}", new_volume); + renderer.set_volume(new_volume)?; + thread::sleep(Duration::from_secs(1)); + println!(" Volume after bump : {}", renderer.volume()?); + + println!(" Restoring original volume: {}", original); + renderer.set_volume(original)?; + + Ok(()) +} diff --git a/pmocontrol/src/avtransport_client.rs b/pmocontrol/src/avtransport_client.rs index b45a313d..65be8da6 100644 --- a/pmocontrol/src/avtransport_client.rs +++ b/pmocontrol/src/avtransport_client.rs @@ -356,3 +356,70 @@ mod upnp_error_tests { assert_eq!(err.error_description, "Invalid Action"); } } + +#[derive(Debug, Clone)] +pub struct PositionInfo { + pub track: u32, + pub track_duration: Option, // HH:MM:SS or None + pub rel_time: Option, // HH:MM:SS or None + pub abs_time: Option, // HH:MM:SS or None +} + +impl AvTransportClient { + /// AVTransport:1 — GetPositionInfo + pub fn get_position_info(&self, instance_id: u32) -> Result { + let instance_id_str = instance_id.to_string(); + let args = [("InstanceID", instance_id_str.as_str())]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetPositionInfo", + &args, + )?; + + if !call_result.status.is_success() { + return Err(anyhow!( + "GetPositionInfo failed with HTTP status {}", + call_result.status + )); + } + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetPositionInfo response"))?; + + parse_position_info(envelope) + } +} + +fn parse_position_info(envelope: &SoapEnvelope) -> Result { + let response = + find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse") + .ok_or_else(|| anyhow!("Missing GetPositionInfoResponse element"))?; + + // Helpers allow missing text (AVTransport allows empty durations) + fn opt(parent: &Element, name: &str) -> Option { + find_child_with_suffix(parent, name) + .and_then(|e| e.get_text()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } + + let track = opt(response, "Track") + .unwrap_or_else(|| "0".into()) + .parse::() + .unwrap_or(0); + + let track_duration = opt(response, "TrackDuration"); + let rel_time = opt(response, "RelTime"); + let abs_time = opt(response, "AbsTime"); + + Ok(PositionInfo { + track, + track_duration, + rel_time, + abs_time, + }) +} diff --git a/pmocontrol/src/capabilities.rs b/pmocontrol/src/capabilities.rs new file mode 100644 index 00000000..461c0940 --- /dev/null +++ b/pmocontrol/src/capabilities.rs @@ -0,0 +1,101 @@ +// pmocontrol/src/capabilities.rs +use anyhow::Result; + +use crate::avtransport_client::PositionInfo; + +/// Logical playback position across backends. +/// +/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que +/// tu préfères pour la façade; ici je reste en String pour garder la +/// même granularité que UPnP sans parser. +#[derive(Clone, Debug)] +pub struct PlaybackPositionInfo { + pub track: Option, + pub rel_time: Option, // position courante + pub abs_time: Option, // si pertinent + pub track_duration: Option, // durée totale +} +pub trait PlaybackPosition { + fn playback_position(&self) -> Result; +} + +/// High-level playback state across backends. +#[derive(Clone, Debug)] +pub enum PlaybackState { + Stopped, + Playing, + Paused, + Transitioning, + NoMedia, + /// Backend-specific or unknown state string. + Unknown(String), +} + + +impl PlaybackState { + /// Map a raw UPnP AVTransport CurrentTransportState string + /// to a logical PlaybackState. + pub fn from_upnp_state(raw: &str) -> Self { + let s = raw.trim().to_ascii_uppercase(); + match s.as_str() { + "STOPPED" => PlaybackState::Stopped, + "PLAYING" => PlaybackState::Playing, + "PAUSED_PLAYBACK" => PlaybackState::Paused, + // States from the AVTransport spec that we normalize: + "PAUSED_RECORDING" => PlaybackState::Paused, + "RECORDING" => PlaybackState::Playing, + // Common vendor-specific states: + "TRANSITIONING" => PlaybackState::Transitioning, + "BUFFERING" | "PREPARING" => PlaybackState::Transitioning, + "NO_MEDIA_PRESENT" => PlaybackState::NoMedia, + _ => PlaybackState::Unknown(raw.to_string()), + } + } +} + + +/// Generic abstraction for playback status (transport state). +/// +/// For UPnP AV, this is backed by AVTransport::GetTransportInfo. +/// For OpenHome, a future implementation will adapt from OH Info/Time. +pub trait PlaybackStatus { + fn playback_state(&self) -> Result; +} + +/// Abstraction générique des capacités de transport (lecture / pause / stop / seek) +/// indépendamment du protocole sous-jacent (UPnP AV, OpenHome, ...). +pub trait TransportControl { + /// Set la ressource à lire (URI + métadonnées) et/ou commence la lecture. + /// + /// Selon l'implémentation, cette méthode peut soit : + /// - faire un "Set...URI" + "Play" (cas UPnP AV), + /// - ou configurer la file de lecture (cas OpenHome, etc.). + fn play_uri(&self, uri: &str, meta: &str) -> Result<()>; + + /// Démarre ou reprend la lecture. + fn play(&self) -> Result<()>; + + /// Met la lecture en pause. + fn pause(&self) -> Result<()>; + + /// Arrête la lecture. + fn stop(&self) -> Result<()>; + + /// Seek à un temps relatif (HH:MM:SS) si supporté. + fn seek_rel_time(&self, hhmmss: &str) -> Result<()>; +} + +/// Abstraction générique des capacités de contrôle de volume / mute. +pub trait VolumeControl { + /// Retourne le volume logique courant (échelle dépendante du renderer). + fn volume(&self) -> Result; + + /// Définit le volume logique (échelle dépendante du renderer). + fn set_volume(&self, v: u16) -> Result<()>; + + /// Indique si le renderer est muet (mute activé). + fn mute(&self) -> Result; + + /// Active ou désactive le mute. + fn set_mute(&self, m: bool) -> Result<()>; +} diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 95997109..f85dda5d 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -1,15 +1,20 @@ +use std::collections::{HashMap, HashSet}; use std::io; use std::sync::{Arc, RwLock}; use std::thread; use std::time::Duration; +use crossbeam_channel::Receiver; use pmoupnp::ssdp::SsdpClient; +use crate::MusicRenderer; +use crate::capabilities::{PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus}; use crate::discovery::DiscoveryManager; -use crate::model::RendererId; +use crate::events::RendererEventBus; +use crate::model::{RendererEvent, RendererId, RendererProtocol}; use crate::provider::HttpXmlDescriptionProvider; use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; -use crate::renderer::Renderer; +use crate::upnp_renderer::UpnpRenderer; /// Control point minimal : /// - lance un SsdpClient dans un thread, @@ -17,6 +22,7 @@ use crate::renderer::Renderer; /// - applique les DeviceUpdate dans le DeviceRegistry. pub struct ControlPoint { registry: Arc>, + event_bus: RendererEventBus, } impl ControlPoint { @@ -25,6 +31,7 @@ impl ControlPoint { /// `timeout_secs` : timeout HTTP pour la récupération des descriptions UPnP. pub fn spawn(timeout_secs: u64) -> io::Result { let registry = Arc::new(RwLock::new(DeviceRegistry::new())); + let event_bus = RendererEventBus::new(); // SsdpClient let client = SsdpClient::new()?; // pmoupnp::ssdp::SsdpClient @@ -71,7 +78,100 @@ impl ControlPoint { }); }); - Ok(Self { registry }) + let runtime_cp = ControlPoint { + registry: Arc::clone(®istry), + event_bus: event_bus.clone(), + }; + + thread::spawn(move || { + let mut cache: HashMap = HashMap::new(); + + loop { + let renderers = { + let reg = runtime_cp.registry.read().unwrap(); + reg.list_renderers() + .into_iter() + .map(|info| UpnpRenderer::from_registry(info, ®)) + .collect::>() + }; + + let mut seen_ids = HashSet::new(); + + for renderer in renderers { + let info = &renderer.info; + + // Ne pas poller les renderers offline + if !info.online { + continue; + } + + match info.protocol { + RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => {} + RendererProtocol::OpenHomeOnly => continue, + } + + let renderer_id = info.id.clone(); + seen_ids.insert(renderer_id.clone()); + + let entry = cache + .entry(renderer_id.clone()) + .or_insert_with(RendererRuntimeSnapshot::default); + + // Keep a snapshot of the previous position to compute logical + // state transitions based on time deltas. + let prev_position = entry.position.clone(); + + // 1) Poll position first, so that the state logic can use the + // freshly updated position when available. + if let Ok(position) = renderer.playback_position() { + let has_changed = match entry.position.as_ref() { + Some(prev) => !playback_position_equal(prev, &position), + None => true, + }; + + if has_changed { + runtime_cp.emit_renderer_event(RendererEvent::PositionChanged { + id: renderer_id.clone(), + position: position.clone(), + }); + } + + entry.position = Some(position); + } + + // 2) Poll raw playback state and compute a logical state that + // compensates for buggy devices (Arylic / LinkPlay). + if let Ok(raw_state) = renderer.playback_state() { + let logical_state = compute_logical_playback_state( + &raw_state, + prev_position.as_ref(), + entry.position.as_ref(), + ); + + let has_changed = match entry.state.as_ref() { + Some(prev) => !playback_state_equal(prev, &logical_state), + None => true, + }; + + if has_changed { + runtime_cp.emit_renderer_event(RendererEvent::StateChanged { + id: renderer_id.clone(), + state: logical_state.clone(), + }); + entry.state = Some(logical_state); + } + } + } + + cache.retain(|id, _| seen_ids.contains(id)); + thread::sleep(Duration::from_secs(1)); + } + }); + + Ok(Self { + registry, + event_bus, + }) } /// Accès au DeviceRegistry partagé. @@ -80,27 +180,168 @@ impl ControlPoint { } /// Snapshot list of renderers currently known by the registry. - pub fn list_renderer_handles(&self) -> Vec { + pub fn list_upnp_renderers(&self) -> Vec { let reg = self.registry.read().unwrap(); reg.list_renderers() .into_iter() - .map(|info| Renderer::from_registry(info, ®)) + .map(|info| UpnpRenderer::from_registry(info, ®)) .collect() } /// Return the first renderer in the registry, if any. - pub fn default_renderer(&self) -> Option { + pub fn default_upnp_renderer(&self) -> Option { let reg = self.registry.read().unwrap(); reg.list_renderers() .into_iter() .next() - .map(|info| Renderer::from_registry(info, ®)) + .map(|info| UpnpRenderer::from_registry(info, ®)) } /// Lookup a renderer by id. - pub fn renderer_by_id(&self, id: &RendererId) -> Option { + pub fn upnp_renderer_by_id(&self, id: &RendererId) -> Option { let reg = self.registry.read().unwrap(); reg.get_renderer(id) - .map(|info| Renderer::from_registry(info, ®)) + .map(|info| UpnpRenderer::from_registry(info, ®)) + } + + /// Snapshot list of music renderers (protocol-agnostic view). + /// + /// For now, only UPnP AV / hybrid renderers are wrapped as + /// [`MusicRenderer::Upnp`]. OpenHome-only devices will be + /// ignored until an OpenHome backend is implemented. + pub fn list_music_renderers(&self) -> Vec { + let reg = self.registry.read().unwrap(); + reg.list_renderers() + .into_iter() + .filter_map(|info| MusicRenderer::from_registry_info(info, ®)) + .collect() + } + + /// Return the first music renderer in the registry, if any. + pub fn default_music_renderer(&self) -> Option { + let reg = self.registry.read().unwrap(); + reg.list_renderers() + .into_iter() + .find_map(|info| MusicRenderer::from_registry_info(info, ®)) + } + + /// Lookup a music renderer by id. + pub fn music_renderer_by_id(&self, id: &RendererId) -> Option { + let reg = self.registry.read().unwrap(); + reg.get_renderer(id) + .and_then(|info| MusicRenderer::from_registry_info(info, ®)) + } + + /// Subscribe to renderer events emitted by the control point runtime. + /// + /// Each subscriber receives all future events independently. + pub fn subscribe_events(&self) -> Receiver { + self.event_bus.subscribe() + } + + #[allow(dead_code)] + pub(crate) fn emit_renderer_event(&self, event: RendererEvent) { + self.event_bus.broadcast(event); } } + +#[derive(Default)] +struct RendererRuntimeSnapshot { + state: Option, + position: Option, +} + +/// Parse "HH:MM:SS" style time strings to seconds. +/// +/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--". +fn parse_hms_to_secs(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + + // Common sentinel values for "no information" in UPnP implementations. + if s == "NOT_IMPLEMENTED" || s == "-:--:--" { + return None; + } + + let parts: Vec<_> = s.split(':').collect(); + if parts.len() != 3 { + return None; + } + + let hours: u64 = parts[0].parse().ok()?; + let minutes: u64 = parts[1].parse().ok()?; + let seconds: u64 = parts[2].parse().ok()?; + + Some(hours * 3600 + minutes * 60 + seconds) +} + +fn parse_optional_hms_to_secs(value: &Option) -> Option { + value.as_ref().and_then(|s| parse_hms_to_secs(s)) +} + +/// Compute a logical playback state by combining the raw AVTransport state +/// with previous and current position information. +/// +/// This is designed to compensate for buggy LinkPlay/Arylic devices that +/// report: +/// - STOPPED while the time actually advances, +/// - NO_MEDIA_PRESENT while track duration is known. +fn compute_logical_playback_state( + raw: &PlaybackState, + prev_position: Option<&PlaybackPositionInfo>, + current_position: Option<&PlaybackPositionInfo>, +) -> PlaybackState { + use PlaybackState::*; + + // Rule 1: Arylic / LinkPlay sometimes report STOPPED while the stream is + // actually playing. If we detect that the relative time advances between + // two polls, we treat this as Playing. + if let Stopped = raw { + if let (Some(prev), Some(curr)) = (prev_position, current_position) { + if let (Some(prev_rel), Some(curr_rel)) = ( + parse_optional_hms_to_secs(&prev.rel_time), + parse_optional_hms_to_secs(&curr.rel_time), + ) { + if curr_rel > prev_rel { + let delta = curr_rel - prev_rel; + // Our poll loop runs every 1s; accept small jitter in the delta. + if delta <= 5 { + return Playing; + } + } + } + } + } + + // Rule 2: Some devices report NO_MEDIA_PRESENT while exposing a non-zero + // track duration. In practice this behaves like a stopped transport with + // a loaded track. + if let NoMedia = raw { + let duration_secs = current_position + .and_then(|p| parse_optional_hms_to_secs(&p.track_duration)) + .or_else(|| prev_position.and_then(|p| parse_optional_hms_to_secs(&p.track_duration))); + + if matches!(duration_secs, Some(d) if d > 0) { + return Stopped; + } + } + + // Fallback: keep the raw (already normalized) state. + raw.clone() +} + +fn playback_state_equal(a: &PlaybackState, b: &PlaybackState) -> bool { + match (a, b) { + (PlaybackState::Unknown(lhs), PlaybackState::Unknown(rhs)) => lhs == rhs, + _ => std::mem::discriminant(a) == std::mem::discriminant(b), + } +} + +fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) -> bool { + a.track == b.track + && a.rel_time == b.rel_time + && a.abs_time == b.abs_time + && a.track_duration == b.track_duration +} diff --git a/pmocontrol/src/events.rs b/pmocontrol/src/events.rs new file mode 100644 index 00000000..900d1cdd --- /dev/null +++ b/pmocontrol/src/events.rs @@ -0,0 +1,33 @@ +use std::sync::{Arc, Mutex}; + +use crossbeam_channel::{unbounded, Receiver, Sender}; + +use crate::model::RendererEvent; + +#[derive(Clone, Default)] +pub(crate) struct RendererEventBus { + subscribers: Arc>>>, +} + +impl RendererEventBus { + pub(crate) fn new() -> Self { + Self { + subscribers: Arc::new(Mutex::new(Vec::new())), + } + } + + pub(crate) fn subscribe(&self) -> Receiver { + let (tx, rx) = unbounded::(); + { + let mut subscribers = self.subscribers.lock().unwrap(); + subscribers.push(tx); + } + rx + } + + #[allow(dead_code)] + pub(crate) fn broadcast(&self, event: RendererEvent) { + let mut subscribers = self.subscribers.lock().unwrap(); + subscribers.retain(|tx| tx.send(event.clone()).is_ok()); + } +} diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index a84eb5cb..aa91326d 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -1,24 +1,30 @@ +mod events; + pub mod avtransport_client; +pub mod capabilities; pub mod connection_manager_client; pub mod control_point; pub mod discovery; pub mod model; pub mod provider; -pub mod renderer; +pub mod upnp_renderer; pub mod rendering_control_client; pub mod registry; pub mod soap_client; +pub mod music_renderer; -pub use avtransport_client::{AvTransportClient, TransportInfo}; +pub use avtransport_client::{AvTransportClient, TransportInfo, PositionInfo}; +pub use capabilities::{TransportControl, VolumeControl, PlaybackState, PlaybackStatus, PlaybackPosition, PlaybackPositionInfo}; pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; pub use control_point::ControlPoint; pub use rendering_control_client::RenderingControlClient; -pub use renderer::Renderer; +pub use upnp_renderer::UpnpRenderer; +pub use music_renderer::MusicRenderer; pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager}; pub use model::{ MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId, - RendererInfo, RendererProtocol, + RendererEvent, RendererInfo, RendererProtocol, }; pub use provider::HttpXmlDescriptionProvider; pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 4442cac2..5c483d6c 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -1,3 +1,5 @@ +use crate::capabilities::{PlaybackPositionInfo, PlaybackState}; + #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct RendererId(pub String); @@ -71,3 +73,23 @@ pub struct MediaServerInfo { pub last_seen: std::time::SystemTime, pub max_age: u32, } + +#[derive(Clone, Debug)] +pub enum RendererEvent { + StateChanged { + id: RendererId, + state: PlaybackState, + }, + PositionChanged { + id: RendererId, + position: PlaybackPositionInfo, + }, + VolumeChanged { + id: RendererId, + volume: u16, + }, + MuteChanged { + id: RendererId, + mute: bool, + }, +} diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs new file mode 100644 index 00000000..fa233722 --- /dev/null +++ b/pmocontrol/src/music_renderer.rs @@ -0,0 +1,162 @@ +// pmocontrol/src/music_renderer.rs + +use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; +use crate::model::{RendererId, RendererInfo, RendererProtocol}; +use crate::{DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, TransportControl, UpnpRenderer, VolumeControl}; +use anyhow::Result; + +/// Music view of a renderer, independent of the underlying protocol/backend. +/// +/// For now, only UPnP AV renderers are supported via [`UpnpRenderer`], +/// but this type is designed to host additional backends (e.g. OpenHome) +/// later on. +#[derive(Clone, Debug)] +pub enum MusicRenderer { + /// UPnP AV / DLNA backend. + Upnp(UpnpRenderer), + // Future backends could be added here, e.g.: + // OpenHome(OpenHomeRenderer), +} + +impl MusicRenderer { + /// Renderer identifier (stable within the registry). + pub fn id(&self) -> &RendererId { + match self { + MusicRenderer::Upnp(r) => r.id(), + } + } + + /// Human-friendly name reported by the device. + pub fn friendly_name(&self) -> &str { + match self { + MusicRenderer::Upnp(r) => r.friendly_name(), + } + } + + /// Protocol classification (UPnP AV only, OpenHome only, hybrid). + pub fn protocol(&self) -> &RendererProtocol { + match self { + MusicRenderer::Upnp(r) => &r.info.protocol, + } + } + + /// Full static info as stored in the registry. + pub fn info(&self) -> &RendererInfo { + match self { + MusicRenderer::Upnp(r) => &r.info, + } + } + + /// Return a reference to the underlying UPnP backend, if any. + pub fn as_upnp(&self) -> Option<&UpnpRenderer> { + match self { + MusicRenderer::Upnp(r) => Some(r), + } + } + + /// Construct a music renderer from a [`RendererInfo`] and the registry. + /// + /// Returns `None` when no supported backend can be built for this renderer. + /// Currently, only UPnP AV / hybrid renderers are mapped to [`MusicRenderer::Upnp`]. + pub fn from_registry_info( + info: RendererInfo, + registry: &DeviceRegistry, + ) -> Option { + match info.protocol { + RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => { + Some(MusicRenderer::Upnp(UpnpRenderer::from_registry(info, registry))) + } + RendererProtocol::OpenHomeOnly => { + // OpenHome-only backend not implemented yet. + None + } + } + } +} + +/// Implémentation générique de `TransportControl` pour [`MusicRenderer`]. +/// +/// Pour l'instant, seule la variante [`MusicRenderer::Upnp`] est supportée, +/// et la délégation se fait vers l'implémentation UPnP AV. +impl TransportControl for MusicRenderer { + fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + match self { + MusicRenderer::Upnp(r) => r.play_uri(uri, meta), + } + } + + fn play(&self) -> Result<()> { + match self { + MusicRenderer::Upnp(r) => TransportControl::play(r), + } + } + + fn pause(&self) -> Result<()> { + match self { + MusicRenderer::Upnp(r) => r.pause(), + } + } + + fn stop(&self) -> Result<()> { + match self { + MusicRenderer::Upnp(r) => r.stop(), + } + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + match self { + MusicRenderer::Upnp(r) => r.seek_rel_time(hhmmss), + } + } +} + +/// Implémentation générique de `VolumeControl` pour [`MusicRenderer`]. +/// +/// Pour l'instant, seule la variante [`MusicRenderer::Upnp`] est supportée, +/// et la délégation se fait vers l'implémentation UPnP RenderingControl. +impl VolumeControl for MusicRenderer { + fn volume(&self) -> Result { + match self { + MusicRenderer::Upnp(r) => r.get_master_volume(), + } + } + + fn set_volume(&self, v: u16) -> Result<()> { + match self { + MusicRenderer::Upnp(r) => r.set_master_volume(v), + } + } + + fn mute(&self) -> Result { + match self { + MusicRenderer::Upnp(r) => r.get_master_mute(), + } + } + + fn set_mute(&self, m: bool) -> Result<()> { + match self { + MusicRenderer::Upnp(r) => r.set_master_mute(m), + } + } +} + +/// Implémentation générique de `PlaybackStatus` pour [`MusicRenderer`]. +/// +/// La variante UPnP délègue à [`UpnpRenderer`]. Les backends OpenHome +/// futurs mapperont l'état OH vers `PlaybackState`. +impl PlaybackStatus for MusicRenderer { + fn playback_state(&self) -> Result { + match self { + MusicRenderer::Upnp(r) => PlaybackStatus::playback_state(r), + } + } +} + +impl PlaybackPosition for MusicRenderer { + fn playback_position(&self) -> Result { + match self { + MusicRenderer::Upnp(r) => r.playback_position(), + // MusicRenderer::OpenHome(r) => r.playback_position(), plus tard + } + } +} \ No newline at end of file diff --git a/pmocontrol/src/renderer.rs b/pmocontrol/src/upnp_renderer.rs similarity index 70% rename from pmocontrol/src/renderer.rs rename to pmocontrol/src/upnp_renderer.rs index bf3114c4..e8f160cd 100644 --- a/pmocontrol/src/renderer.rs +++ b/pmocontrol/src/upnp_renderer.rs @@ -1,20 +1,22 @@ use anyhow::{anyhow, Result}; +use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; use crate::connection_manager_client::{ ConnectionInfo, ConnectionManagerClient, ProtocolInfo, }; use crate::rendering_control_client::RenderingControlClient; -use crate::{AvTransportClient, DeviceRegistry, RendererId, RendererInfo}; +use crate::{AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererId, RendererInfo, TransportControl, VolumeControl}; /// High-level handle representing a renderer and its optional AVTransport client. -pub struct Renderer { +#[derive(Clone, Debug)] +pub struct UpnpRenderer { pub info: RendererInfo, avtransport: Option, rendering_control: Option, connection_manager: Option, } -impl Renderer { +impl UpnpRenderer { pub fn id(&self) -> &RendererId { &self.info.id } @@ -167,7 +169,7 @@ mod tests { fn renderer_without_avtransport() { let info = renderer_info("no-avt", false); let registry = registry_with_renderer(info.clone()); - let renderer = Renderer::from_registry(info, ®istry); + let renderer = UpnpRenderer::from_registry(info, ®istry); assert_eq!(renderer.has_avtransport(), false); assert_eq!(renderer.id().0, "renderer-no-avt"); @@ -177,9 +179,84 @@ mod tests { fn renderer_with_avtransport() { let info = renderer_info("with-avt", true); let registry = registry_with_renderer(info.clone()); - let renderer = Renderer::from_registry(info, ®istry); + let renderer = UpnpRenderer::from_registry(info, ®istry); assert!(renderer.has_avtransport()); assert_eq!(renderer.friendly_name(), "Renderer with-avt"); } } + + +/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`]. +/// +/// Cette impl se base sur AVTransport (InstanceID = 0). +impl TransportControl for UpnpRenderer { + fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + self.play_uri(uri, meta) + } + + fn play(&self) -> Result<()> { + let avt = self.avtransport()?; + avt.play(0, "1") + } + + fn pause(&self) -> Result<()> { + self.pause() + } + + fn stop(&self) -> Result<()> { + self.stop() + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + self.seek_rel_time(hhmmss) + } +} + +/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`]. +/// +/// Cette impl se base sur le channel "Master" (InstanceID = 0). +impl VolumeControl for UpnpRenderer { + fn volume(&self) -> Result { + self.get_master_volume() + } + + fn set_volume(&self, v: u16) -> Result<()> { + self.set_master_volume(v) + } + + fn mute(&self) -> Result { + self.get_master_mute() + } + + fn set_mute(&self, m: bool) -> Result<()> { + self.set_master_mute(m) + } +} + +/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`]. +/// +/// Utilise AVTransport::GetTransportInfo(InstanceID=0). +impl PlaybackStatus for UpnpRenderer { + fn playback_state(&self) -> Result { + let avt = self.avtransport()?; + let info = avt.get_transport_info(0)?; + Ok(PlaybackState::from_upnp_state( + &info.current_transport_state, + )) + } +} + +impl PlaybackPosition for UpnpRenderer { + fn playback_position(&self) -> Result { + let avt = self.avtransport()?; + let raw: PositionInfo = avt.get_position_info(0)?; + + Ok(PlaybackPositionInfo { + track: Some(raw.track), + rel_time: raw.rel_time, + abs_time: raw.abs_time, + track_duration: raw.track_duration, + }) + } +} \ No newline at end of file