diff --git a/pmocontrol/examples/renderer_demo.rs b/pmocontrol/examples/renderer_demo.rs new file mode 100644 index 00000000..5c05d363 --- /dev/null +++ b/pmocontrol/examples/renderer_demo.rs @@ -0,0 +1,229 @@ +// pmocontrol/examples/renderer_demo.rs + +use anyhow::{anyhow, Result}; +use pmocontrol::{ControlPoint, Renderer}; +use std::env; +use std::io::{self, Write}; +use std::thread; +use std::time::Duration; + +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 + // ------------------------------------------------------------------------- + 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(); + + // Filtre : on ignore le renderer PMOMusic interne en développement + renderers.retain(|r| { + !r.friendly_name() + .to_ascii_lowercase() + .contains("pmomusic audio renderer") + }); + + if renderers.is_empty() { + println!("No valid UPnP MediaRenderer discovered."); + return Ok(()); + } + + println!("\nDiscovered MediaRenderers:"); + for (idx, r) in renderers.iter().enumerate() { + 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(), + ); + } + + if renderer_index >= renderers.len() { + return Err(anyhow!( + "Renderer index {} out of range (0..={})", + renderer_index, + renderers.len().saturating_sub(1) + )); + } + + // ------------------------------------------------------------------------- + // 3. Sélection du renderer + // ------------------------------------------------------------------------- + let renderer = &renderers[renderer_index]; + + 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); + } + } + } else { + println!("\n[RenderingControl]"); + println!(" Renderer has no RenderingControl service."); + } + + // ------------------------------------------------------------------------- + // 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!("\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); + } + } + + // 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); + } + } + + // 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!("\nDone."); + Ok(()) +} diff --git a/pmocontrol/examples/test_avtransport.rs b/pmocontrol/examples/test_avtransport.rs index cbfe72c3..58302ab1 100644 --- a/pmocontrol/examples/test_avtransport.rs +++ b/pmocontrol/examples/test_avtransport.rs @@ -1,101 +1,158 @@ +use anyhow::{anyhow, Result}; +use pmocontrol::{ControlPoint, DeviceRegistryRead, RendererInfo}; +use std::env; +use std::io::{self, Write}; use std::thread; use std::time::Duration; -use anyhow::{Context, Result}; -use pmocontrol::{ - ControlPoint, - DeviceRegistryRead, - DiscoveredEndpoint, - HttpXmlDescriptionProvider, -}; - fn main() -> Result<()> { - // Optionnel mais pratique si tu as déjà tracing dans la stack - tracing_subscriber::fmt::init(); + // Default values + 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; - // 1. Créer le control point et lancer la découverte SSDP - // - // timeout_secs = 5 pour les HTTP GET des description.xml - println!("Starting control point (HTTP timeout = 5s)..."); - let cp = ControlPoint::spawn(5).context("Failed to spawn ControlPoint")?; + // Args: + // - si args[1] est un entier : index, args[2] éventuel = uri + // - sinon : args[1] = uri, args[2] éventuel = index + let args: Vec = env::args().collect(); + if args.len() >= 2 { + let first = &args[1]; + if let Ok(idx) = first.parse::() { + // cas: avtransport_demo 1 [URI] + renderer_index = idx; + if args.len() >= 3 { + uri = args[2].clone(); + } + } else { + // cas: avtransport_demo URI [INDEX] + uri = first.clone(); + if args.len() >= 3 { + if let Ok(idx) = args[2].parse::() { + renderer_index = idx; + } + } + } + } - let registry = cp.registry(); + println!("Using URI: {}", uri); + println!("Requested renderer index: {}", renderer_index); - // 2. Laisser un peu de temps à la découverte - println!("Waiting 5 seconds for UPnP discovery..."); + // 1. Start control point and let discovery run a bit + let cp = ControlPoint::spawn(5)?; thread::sleep(Duration::from_secs(5)); - // 3. Lister les renderers connus - let renderers = { - let reg = registry.read().expect("DeviceRegistry RwLock poisoned"); - reg.list_renderers() - }; + // 2. Get registry snapshot + let registry = cp.registry(); + let reg = registry.read().unwrap(); + let all_renderers = reg.list_renderers(); + + // Filter out the in-dev PMOMusic renderer + let renderers: Vec = all_renderers + .into_iter() + .filter(|r| { + !r.friendly_name + .to_ascii_lowercase() + .contains("pmomusic audio renderer") + }) + .collect(); if renderers.is_empty() { - println!("No UPnP renderers discovered."); + println!("No valid UPnP MediaRenderer discovered."); return Ok(()); } - println!("Discovered renderers:"); + println!("Discovered MediaRenderers:"); for (idx, r) in renderers.iter().enumerate() { println!( - " [{}] {} (model: {}, UDN: {})", - idx, r.friendly_name, r.model_name, r.udn + " [{}] {} | model={} | udn={} | location={}", + idx, + r.friendly_name, + r.model_name, + r.udn, + r.location, ); } - // 4. Choisir un renderer (ici: le premier) - let renderer = renderers[0].clone(); - println!( - "\nUsing renderer [0]: {} (model: {}, UDN: {})", - renderer.friendly_name, renderer.model_name, renderer.udn - ); - println!("Location: {}", renderer.location); + if renderer_index >= renderers.len() { + return Err(anyhow!( + "Renderer index {} out of range (0..={})", + renderer_index, + renderers.len().saturating_sub(1) + )); + } - // 5. Construire un DiscoveredEndpoint minimal pour ce renderer - // - // On reconstruit un endpoint à partir des infos du registry. - // Ça permet de réutiliser HttpXmlDescriptionProvider::build_avtransport_client - // qui sait parser description.xml et trouver le service AVTransport. - let endpoint = DiscoveredEndpoint::new( - renderer.udn.clone(), - renderer.location.clone(), - renderer.server_header.clone(), - renderer.max_age, - ); + // 3. Selection by index + let renderer: &RendererInfo = &renderers[renderer_index]; - // 6. Construire un client AVTransport à partir de cet endpoint - let provider = HttpXmlDescriptionProvider::new(5); - let client_opt = provider - .build_avtransport_client(&endpoint) - .context("Failed to build AVTransport client from device description")?; + println!("\nSelected renderer (index {}):", renderer_index); + println!(" Name : {}", renderer.friendly_name); + println!(" Model : {}", renderer.model_name); + println!(" Manufacturer: {}", renderer.manufacturer); + println!(" UDN : {}", renderer.udn); + println!(" Location : {}", renderer.location); - let client = match client_opt { - Some(c) => c, - None => { - println!( - "Renderer {} has no AVTransport service (or no controlURL).", - renderer.friendly_name - ); - return Ok(()); - } + // 4. Get AVTransport client + let avtransport = reg + .avtransport_client_for_renderer(&renderer.id) + .expect("Selected renderer has no AVTransport service"); + + println!(" AVTransport control URL : {}", avtransport.control_url); + println!(" AVTransport service type: {}", avtransport.service_type); + + // We are done with the registry lock + drop(reg); + + // 5. Configure the URI + let meta = ""; // or a full DIDL-Lite string + + println!("\nCalling SetAVTransportURI..."); + avtransport.set_av_transport_uri(&uri, meta)?; + println!(" SetAVTransportURI: OK"); + + // Helper closure to dump current TransportInfo + let dump_info = |label: &str| -> Result<()> { + let info = avtransport.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(()) }; - println!( - "AVTransport endpoint:\n service_type = {}\n control_url = {}", - client.service_type, client.control_url - ); + dump_info("After SetAVTransportURI")?; - // 7. Appeler GetTransportInfo(InstanceID=0) - println!("\nCalling GetTransportInfo (InstanceID = 0)..."); - let info = client - .get_transport_info(0) - .context("GetTransportInfo SOAP call failed")?; + // 6. Play + println!("\nCalling Play (Speed=\"1\")..."); + avtransport.play(0, "1")?; + println!(" Play: OK"); + thread::sleep(Duration::from_secs(20)); + dump_info("After Play")?; - println!("GetTransportInfo result:"); - println!(" CurrentTransportState = {}", info.current_transport_state); - println!(" CurrentTransportStatus = {}", info.current_transport_status); - println!(" CurrentSpeed = {}", info.current_speed); + // 7. Optional: wait before Pause/Stop + print!("\nPress ENTER to Pause..."); + io::stdout().flush().ok(); + let _ = io::stdin().read_line(&mut String::new()); + // 8. Pause + println!("\nCalling Pause..."); + if let Err(e) = avtransport.pause(0) { + println!(" Pause failed: {e}"); + } else { + println!(" Pause: OK"); + thread::sleep(Duration::from_secs(2)); + dump_info("After Pause")?; + } + + // 9. Stop + println!("\nCalling Stop..."); + if let Err(e) = avtransport.stop(0) { + println!(" Stop failed: {e}"); + } else { + println!(" Stop: OK"); + dump_info("After Stop")?; + } + + println!("\nDone."); Ok(()) } diff --git a/pmocontrol/src/avtransport_client.rs b/pmocontrol/src/avtransport_client.rs index a3174510..b45a313d 100644 --- a/pmocontrol/src/avtransport_client.rs +++ b/pmocontrol/src/avtransport_client.rs @@ -1,5 +1,7 @@ +// pmocontrol/src/avtransport_client.rs + use anyhow::{anyhow, Result}; -use crate::soap_client::invoke_upnp_action; +use crate::soap_client::{invoke_upnp_action, SoapCallResult}; use pmoupnp::soap::SoapEnvelope; use xmltree::{Element, XMLNode}; @@ -24,6 +26,7 @@ impl AvTransportClient { } } + /// AVTransport:1 — GetTransportInfo pub fn get_transport_info(&self, instance_id: u32) -> Result { let instance_id_str = instance_id.to_string(); let args = [("InstanceID", instance_id_str.as_str())]; @@ -49,6 +52,131 @@ impl AvTransportClient { parse_transport_info(envelope) } + + /// AVTransport:1 — SetAVTransportURI + /// + /// Pour l’instant on force `InstanceID = 0`, ce qui couvre la majorité + /// des MediaRenderers UPnP AV (un seul instance de transport). + /// + /// - `uri` : CurrentURI + /// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide) + pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<()> { + let args = [ + ("InstanceID", "0"), + ("CurrentURI", uri), + ("CurrentURIMetaData", meta), + ]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "SetAVTransportURI", + &args, + )?; + + handle_action_response("SetAVTransportURI", &call_result) + } + + /// AVTransport:1 — Play + pub fn play(&self, instance_id: u32, speed: &str) -> Result<()> { + let instance_id_str = instance_id.to_string(); + let args = [ + ("InstanceID", instance_id_str.as_str()), + ("Speed", speed), + ]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "Play", + &args, + )?; + + handle_action_response("Play", &call_result) + } + + /// AVTransport:1 — Pause + pub fn pause(&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, + "Pause", + &args, + )?; + + handle_action_response("Pause", &call_result) + } + + /// AVTransport:1 — Stop + pub fn stop(&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, + "Stop", + &args, + )?; + + handle_action_response("Stop", &call_result) + } + + /// AVTransport:1 — Seek + pub fn seek(&self, instance_id: u32, unit: &str, target: &str) -> Result<()> { + let instance_id_str = instance_id.to_string(); + let args = [ + ("InstanceID", instance_id_str.as_str()), + ("Unit", unit), + ("Target", target), + ]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "Seek", + &args, + )?; + + handle_action_response("Seek", &call_result) + } +} + +fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> { + if !call_result.status.is_success() { + if let Some(env) = &call_result.envelope { + if let Some(upnp_error) = parse_upnp_error(env) { + return Err(anyhow!( + "{action} failed with UPnP error {}: {} (HTTP status {})", + upnp_error.error_code, + upnp_error.error_description, + call_result.status + )); + } + } + + return Err(anyhow!( + "{action} failed with HTTP status {} and body: {}", + call_result.status, + call_result.raw_body + )); + } + + if let Some(env) = &call_result.envelope { + if let Some(upnp_error) = parse_upnp_error(env) { + return Err(anyhow!( + "{action} returned UPnP error {}: {} (HTTP status {})", + upnp_error.error_code, + upnp_error.error_description, + call_result.status + )); + } + } + + Ok(()) } fn parse_transport_info(envelope: &SoapEnvelope) -> Result { @@ -68,6 +196,64 @@ fn parse_transport_info(envelope: &SoapEnvelope) -> Result { }) } +/// Représente une erreur UPnP extraite d’un SOAP Fault. +#[derive(Debug, Clone)] +struct UpnpError { + pub error_code: u32, + pub error_description: String, +} + +/// Parse un éventuel SOAP Fault contenant un UPnPError. +/// +/// Schéma typique (SOAP 1.1) : +/// +/// ```xml +/// +/// +/// ... +/// ... +/// +/// +/// 401 +/// Invalid Action +/// +/// +/// +/// +/// ``` +fn parse_upnp_error(envelope: &SoapEnvelope) -> Option { + let fault = find_child_with_suffix(&envelope.body.content, "Fault")?; + let detail = find_child_with_suffix(fault, "detail")?; + let upnp_error = find_child_with_suffix(detail, "UPnPError")?; + + // errorCode (obligatoire dans la spec) + let error_code_elem = upnp_error.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem), + _ => None, + })?; + + let binding = error_code_elem.get_text()?; + let error_code_text = binding.trim(); + let error_code = error_code_text.parse::().ok()?; + + // errorDescription (optionnel, mais utile) + let error_description = upnp_error + .children + .iter() + .find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => { + elem.get_text().map(|t| t.trim().to_string()) + } + _ => None, + }) + .unwrap_or_else(|| String::from("")); + + Some(UpnpError { + error_code, + error_description, + }) +} + fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> { parent.children.iter().find_map(|node| match node { XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), @@ -110,7 +296,9 @@ mod tests { "CurrentTransportStatus", "OK", ))); - response.children.push(XMLNode::Element(text_element("CurrentSpeed", "1"))); + response + .children + .push(XMLNode::Element(text_element("CurrentSpeed", "1"))); let mut body = Element::new("s:Body"); body.children.push(XMLNode::Element(response)); @@ -126,3 +314,45 @@ mod tests { assert_eq!(info.current_speed, "1"); } } + +#[cfg(test)] +mod upnp_error_tests { + use super::*; + use pmoupnp::soap::{SoapBody, SoapEnvelope}; + + fn text_element(name: &str, text: &str) -> Element { + let mut elem = Element::new(name); + elem.children.push(XMLNode::Text(text.to_string())); + elem + } + + #[test] + fn parse_upnp_error_extracts_error_code_and_description() { + let error_code = text_element("errorCode", "401"); + let error_description = text_element("errorDescription", "Invalid Action"); + + let mut upnp_error = Element::new("UPnPError"); + upnp_error.children.push(XMLNode::Element(error_code)); + upnp_error + .children + .push(XMLNode::Element(error_description)); + + let mut detail = Element::new("detail"); + detail.children.push(XMLNode::Element(upnp_error)); + + let mut fault = Element::new("s:Fault"); + fault.children.push(XMLNode::Element(detail)); + + let mut body = Element::new("s:Body"); + body.children.push(XMLNode::Element(fault)); + + let envelope = SoapEnvelope { + header: None, + body: SoapBody { content: body }, + }; + + let err = parse_upnp_error(&envelope).expect("Expected UPnPError"); + assert_eq!(err.error_code, 401); + assert_eq!(err.error_description, "Invalid Action"); + } +} diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 35a4b472..95997109 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -6,8 +6,10 @@ use std::time::Duration; use pmoupnp::ssdp::SsdpClient; use crate::discovery::DiscoveryManager; +use crate::model::RendererId; use crate::provider::HttpXmlDescriptionProvider; -use crate::registry::{DeviceRegistry, DeviceUpdate}; +use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; +use crate::renderer::Renderer; /// Control point minimal : /// - lance un SsdpClient dans un thread, @@ -76,4 +78,29 @@ impl ControlPoint { pub fn registry(&self) -> Arc> { Arc::clone(&self.registry) } + + /// Snapshot list of renderers currently known by the registry. + pub fn list_renderer_handles(&self) -> Vec { + let reg = self.registry.read().unwrap(); + reg.list_renderers() + .into_iter() + .map(|info| Renderer::from_registry(info, ®)) + .collect() + } + + /// Return the first renderer in the registry, if any. + pub fn default_renderer(&self) -> Option { + let reg = self.registry.read().unwrap(); + reg.list_renderers() + .into_iter() + .next() + .map(|info| Renderer::from_registry(info, ®)) + } + + /// Lookup a renderer by id. + pub fn renderer_by_id(&self, id: &RendererId) -> Option { + let reg = self.registry.read().unwrap(); + reg.get_renderer(id) + .map(|info| Renderer::from_registry(info, ®)) + } } diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 39acd3e8..c401c2bd 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -3,11 +3,15 @@ pub mod control_point; pub mod discovery; pub mod model; pub mod provider; +pub mod renderer; +pub mod rendering_control_client; pub mod registry; pub mod soap_client; pub use avtransport_client::{AvTransportClient, TransportInfo}; pub use control_point::ControlPoint; +pub use rendering_control_client::RenderingControlClient; +pub use renderer::Renderer; pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager}; pub use model::{ diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 13f2c177..e6e11a2c 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -43,6 +43,8 @@ pub struct RendererInfo { pub avtransport_service_type: Option, pub avtransport_control_url: Option, + pub rendering_control_service_type: Option, + pub rendering_control_control_url: Option, } #[derive(Clone, Debug, Default)] diff --git a/pmocontrol/src/provider.rs b/pmocontrol/src/provider.rs index 8da05831..491b6e29 100644 --- a/pmocontrol/src/provider.rs +++ b/pmocontrol/src/provider.rs @@ -42,6 +42,10 @@ struct ParsedDeviceDescription { // New: AVTransport endpoint (if present in serviceList) avtransport_service_type: Option, avtransport_control_url: Option, + + // RenderingControl endpoint (if present in serviceList) + rendering_control_service_type: Option, + rendering_control_control_url: Option, } impl ParsedDeviceDescription { @@ -161,6 +165,19 @@ impl HttpXmlDescriptionProvider { ); } } + + if lower + .contains("urn:schemas-upnp-org:service:renderingcontrol:") + { + if parsed.rendering_control_service_type.is_none() { + parsed.rendering_control_service_type = Some(st.clone()); + parsed.rendering_control_control_url = Some(ctrl.clone()); + debug!( + "Found RenderingControl service for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } } in_service = false; @@ -260,6 +277,11 @@ impl HttpXmlDescriptionProvider { .avtransport_control_url .as_ref() .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), + rendering_control_service_type: parsed.rendering_control_service_type.clone(), + rendering_control_control_url: parsed + .rendering_control_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), }) } @@ -463,4 +485,4 @@ impl DeviceDescriptionProvider for HttpXmlDescriptionProvider { } } } -} \ No newline at end of file +} diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs index d618bb6f..866012d0 100644 --- a/pmocontrol/src/registry.rs +++ b/pmocontrol/src/registry.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::time::SystemTime; use crate::avtransport_client::AvTransportClient; +use crate::rendering_control_client::RenderingControlClient; use crate::model::{MediaServerId, MediaServerInfo, RendererId, RendererInfo}; #[derive(Clone, Debug)] @@ -158,4 +159,20 @@ impl DeviceRegistry { Some(AvTransportClient::new(control_url, service_type)) } + + /// Construct a RenderingControlClient for a given renderer id, if possible. + pub fn rendering_control_client_for_renderer( + &self, + id: &RendererId, + ) -> Option { + let info = self.renderers.get(id)?; + + let service_type = info.rendering_control_service_type.as_ref()?; + let control_url = info.rendering_control_control_url.as_ref()?; + + Some(RenderingControlClient::new( + control_url.clone(), + service_type.clone(), + )) + } } diff --git a/pmocontrol/src/renderer.rs b/pmocontrol/src/renderer.rs new file mode 100644 index 00000000..9889c1a9 --- /dev/null +++ b/pmocontrol/src/renderer.rs @@ -0,0 +1,152 @@ +use anyhow::{anyhow, Result}; + +use crate::rendering_control_client::RenderingControlClient; +use crate::{AvTransportClient, DeviceRegistry, RendererId, RendererInfo}; + +/// High-level handle representing a renderer and its optional AVTransport client. +pub struct Renderer { + pub info: RendererInfo, + avtransport: Option, + rendering_control: Option, +} + +impl Renderer { + pub fn id(&self) -> &RendererId { + &self.info.id + } + + pub fn friendly_name(&self) -> &str { + &self.info.friendly_name + } + + pub fn has_avtransport(&self) -> bool { + self.avtransport.is_some() + } + + pub fn has_rendering_control(&self) -> bool { + self.rendering_control.is_some() + } + + pub fn avtransport(&self) -> Result<&AvTransportClient> { + self.avtransport + .as_ref() + .ok_or_else(|| anyhow!("Renderer has no AVTransport service")) + } + + pub fn rendering_control(&self) -> Result<&RenderingControlClient> { + self.rendering_control + .as_ref() + .ok_or_else(|| anyhow!("Renderer has no RenderingControl service")) + } + + pub fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + let avt = self.avtransport()?; + avt.set_av_transport_uri(uri, meta)?; + avt.play(0, "1") + } + + pub fn pause(&self) -> Result<()> { + let avt = self.avtransport()?; + avt.pause(0) + } + + pub fn stop(&self) -> Result<()> { + let avt = self.avtransport()?; + avt.stop(0) + } + + pub fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + let avt = self.avtransport()?; + avt.seek(0, "REL_TIME", hhmmss) + } + + pub fn get_master_volume(&self) -> Result { + let rc = self.rendering_control()?; + rc.get_volume(0, "Master") + } + + pub fn set_master_volume(&self, volume: u16) -> Result<()> { + let rc = self.rendering_control()?; + rc.set_volume(0, "Master", volume) + } + + pub fn get_master_mute(&self) -> Result { + let rc = self.rendering_control()?; + rc.get_mute(0, "Master") + } + + pub fn set_master_mute(&self, mute: bool) -> Result<()> { + let rc = self.rendering_control()?; + rc.set_mute(0, "Master", mute) + } + + pub fn from_registry(info: RendererInfo, registry: &DeviceRegistry) -> Self { + let avtransport = registry.avtransport_client_for_renderer(&info.id); + let rendering_control = registry.rendering_control_client_for_renderer(&info.id); + Self { + info, + avtransport, + rendering_control, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{RendererCapabilities, RendererProtocol}; + use crate::registry::{DeviceRegistry, DeviceUpdate}; + use std::time::SystemTime; + + fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo { + RendererInfo { + id: RendererId(format!("renderer-{id_suffix}")), + udn: format!("uuid:renderer-{id_suffix}"), + friendly_name: format!("Renderer {id_suffix}"), + model_name: "Model".into(), + manufacturer: "Manufacturer".into(), + protocol: RendererProtocol::UpnpAvOnly, + capabilities: RendererCapabilities { + has_avtransport: with_avtransport, + ..RendererCapabilities::default() + }, + location: "http://127.0.0.1/device.xml".into(), + server_header: "TestServer/1.0".into(), + online: true, + last_seen: SystemTime::now(), + max_age: 1800, + avtransport_service_type: with_avtransport + .then(|| "urn:schemas-upnp-org:service:AVTransport:1".into()), + avtransport_control_url: with_avtransport + .then(|| "http://127.0.0.1/avtransport".into()), + rendering_control_service_type: None, + rendering_control_control_url: None, + } + } + + fn registry_with_renderer(info: RendererInfo) -> DeviceRegistry { + let mut registry = DeviceRegistry::new(); + registry.apply_update(DeviceUpdate::RendererOnline(info)); + registry + } + + #[test] + 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); + + assert_eq!(renderer.has_avtransport(), false); + assert_eq!(renderer.id().0, "renderer-no-avt"); + } + + #[test] + 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); + + assert!(renderer.has_avtransport()); + assert_eq!(renderer.friendly_name(), "Renderer with-avt"); + } +} diff --git a/pmocontrol/src/rendering_control_client.rs b/pmocontrol/src/rendering_control_client.rs new file mode 100644 index 00000000..0eb3bc46 --- /dev/null +++ b/pmocontrol/src/rendering_control_client.rs @@ -0,0 +1,248 @@ +use anyhow::{anyhow, Result}; +use crate::soap_client::{invoke_upnp_action, SoapCallResult}; +use pmoupnp::soap::SoapEnvelope; +use xmltree::{Element, XMLNode}; + +#[derive(Debug, Clone)] +pub struct RenderingControlClient { + pub control_url: String, + pub service_type: String, +} + +impl RenderingControlClient { + pub fn new(control_url: String, service_type: String) -> Self { + Self { + control_url, + service_type, + } + } + + /// RenderingControl:1 — GetVolume + pub fn get_volume(&self, instance_id: u32, channel: &str) -> Result { + let instance_id_str = instance_id.to_string(); + let args = [ + ("InstanceID", instance_id_str.as_str()), + ("Channel", channel), + ]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetVolume", + &args, + )?; + + ensure_success("GetVolume", &call_result)?; + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetVolume response"))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(anyhow!( + "GetVolume returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + + let response = + find_child_with_suffix(&envelope.body.content, "GetVolumeResponse") + .ok_or_else(|| anyhow!("Missing GetVolumeResponse element in SOAP body"))?; + + let text = extract_child_text(response, "CurrentVolume")?; + let volume = text + .parse::() + .map_err(|_| anyhow!("Invalid CurrentVolume value: {}", text))?; + + Ok(volume) + } + + /// RenderingControl:1 — SetVolume + pub fn set_volume(&self, instance_id: u32, channel: &str, volume: u16) -> Result<()> { + let instance_id_str = instance_id.to_string(); + let volume_str = volume.to_string(); + let args = [ + ("InstanceID", instance_id_str.as_str()), + ("Channel", channel), + ("DesiredVolume", volume_str.as_str()), + ]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "SetVolume", + &args, + )?; + + handle_action_response("SetVolume", &call_result) + } + + /// RenderingControl:1 — GetMute + pub fn get_mute(&self, instance_id: u32, channel: &str) -> Result { + let instance_id_str = instance_id.to_string(); + let args = [ + ("InstanceID", instance_id_str.as_str()), + ("Channel", channel), + ]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetMute", + &args, + )?; + + ensure_success("GetMute", &call_result)?; + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetMute response"))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(anyhow!( + "GetMute returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + + let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse") + .ok_or_else(|| anyhow!("Missing GetMuteResponse element in SOAP body"))?; + + let text = extract_child_text(response, "CurrentMute")?; + let mute = match text.as_str() { + "0" => false, + "1" => true, + _ => { + return Err(anyhow!( + "Invalid CurrentMute value: {} (expected 0 or 1)", + text + )) + } + }; + + Ok(mute) + } + + /// RenderingControl:1 — SetMute + pub fn set_mute(&self, instance_id: u32, channel: &str, mute: bool) -> Result<()> { + let instance_id_str = instance_id.to_string(); + let mute_str = if mute { "1" } else { "0" }; + let args = [ + ("InstanceID", instance_id_str.as_str()), + ("Channel", channel), + ("DesiredMute", mute_str), + ]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "SetMute", + &args, + )?; + + handle_action_response("SetMute", &call_result) + } +} + +fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<()> { + if call_result.status.is_success() { + return Ok(()); + } + + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + return Err(anyhow!( + "{action} failed with UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + } + + Err(anyhow!( + "{action} failed with HTTP status {} and body: {}", + call_result.status, + call_result.raw_body + )) +} + +fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> { + ensure_success(action, call_result)?; + + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + return Err(anyhow!( + "{action} returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + } + + Ok(()) +} + +#[derive(Debug, Clone)] +struct UpnpError { + pub error_code: u32, + pub error_description: String, +} + +fn parse_upnp_error(envelope: &SoapEnvelope) -> Option { + let fault = find_child_with_suffix(&envelope.body.content, "Fault")?; + let detail = find_child_with_suffix(fault, "detail")?; + let upnp_error = find_child_with_suffix(detail, "UPnPError")?; + + let error_code_elem = upnp_error.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem), + _ => None, + })?; + + let binding = error_code_elem.get_text()?; + let error_code_text = binding.trim(); + let error_code = error_code_text.parse::().ok()?; + + let error_description = upnp_error + .children + .iter() + .find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => { + elem.get_text().map(|t| t.trim().to_string()) + } + _ => None, + }) + .unwrap_or_else(|| String::from("")); + + Some(UpnpError { + error_code, + error_description, + }) +} + +fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> { + parent.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), + _ => None, + }) +} + +fn extract_child_text(parent: &Element, suffix: &str) -> Result { + let child = find_child_with_suffix(parent, suffix) + .ok_or_else(|| anyhow!("Missing {suffix} element in response"))?; + + let text = child + .get_text() + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .ok_or_else(|| anyhow!("{suffix} element missing text in response"))?; + + Ok(text) +}