From bed0eca7f692bb904ef6ba88df74649b28e226cc Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 29 Nov 2025 22:59:07 +0100 Subject: [PATCH] avtransport client --- Cargo.lock | 1 + pmocontrol/Cargo.toml | 1 + pmocontrol/examples/discover.rs | 2 +- pmocontrol/examples/test_avtransport.rs | 101 +++++++++++++++++++ pmocontrol/src/avtransport_client.rs | 128 ++++++++++++++++++++++++ pmocontrol/src/discovery.rs | 15 ++- pmocontrol/src/lib.rs | 8 +- pmocontrol/src/model.rs | 3 + pmocontrol/src/provider.rs | 122 ++++++++++++++++++++-- pmocontrol/src/registry.rs | 44 ++++++++ pmocontrol/src/soap_client.rs | 37 +++---- 11 files changed, 412 insertions(+), 50 deletions(-) create mode 100644 pmocontrol/examples/test_avtransport.rs create mode 100644 pmocontrol/src/avtransport_client.rs diff --git a/Cargo.lock b/Cargo.lock index e3fc36b1..e01f7c48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3169,6 +3169,7 @@ dependencies = [ "tracing", "tracing-subscriber", "ureq", + "xmltree 0.11.0", ] [[package]] diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index ee2b43a3..22611edd 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -11,3 +11,4 @@ ureq = "3.1.4" tracing = "0.1.41" tracing-subscriber = "0.3" anyhow = "1.0" +xmltree = "0.11.0" diff --git a/pmocontrol/examples/discover.rs b/pmocontrol/examples/discover.rs index e1fbfe32..3e4dd991 100644 --- a/pmocontrol/examples/discover.rs +++ b/pmocontrol/examples/discover.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use std::thread; use std::time::Duration; -use pmocontrol::{ControlPoint, DeviceRegistryRead, MediaServerInfo, RendererInfo}; use pmocontrol::RendererProtocol; +use pmocontrol::{ControlPoint, DeviceRegistryRead, MediaServerInfo, RendererInfo}; fn main() -> std::io::Result<()> { // Un tout petit logging optionnel diff --git a/pmocontrol/examples/test_avtransport.rs b/pmocontrol/examples/test_avtransport.rs new file mode 100644 index 00000000..cbfe72c3 --- /dev/null +++ b/pmocontrol/examples/test_avtransport.rs @@ -0,0 +1,101 @@ +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(); + + // 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")?; + + let registry = cp.registry(); + + // 2. Laisser un peu de temps à la découverte + println!("Waiting 5 seconds for UPnP discovery..."); + thread::sleep(Duration::from_secs(5)); + + // 3. Lister les renderers connus + let renderers = { + let reg = registry.read().expect("DeviceRegistry RwLock poisoned"); + reg.list_renderers() + }; + + if renderers.is_empty() { + println!("No UPnP renderers discovered."); + return Ok(()); + } + + println!("Discovered renderers:"); + for (idx, r) in renderers.iter().enumerate() { + println!( + " [{}] {} (model: {}, UDN: {})", + idx, r.friendly_name, r.model_name, r.udn + ); + } + + // 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); + + // 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, + ); + + // 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")?; + + let client = match client_opt { + Some(c) => c, + None => { + println!( + "Renderer {} has no AVTransport service (or no controlURL).", + renderer.friendly_name + ); + return Ok(()); + } + }; + + println!( + "AVTransport endpoint:\n service_type = {}\n control_url = {}", + client.service_type, client.control_url + ); + + // 7. Appeler GetTransportInfo(InstanceID=0) + println!("\nCalling GetTransportInfo (InstanceID = 0)..."); + let info = client + .get_transport_info(0) + .context("GetTransportInfo SOAP call failed")?; + + println!("GetTransportInfo result:"); + println!(" CurrentTransportState = {}", info.current_transport_state); + println!(" CurrentTransportStatus = {}", info.current_transport_status); + println!(" CurrentSpeed = {}", info.current_speed); + + Ok(()) +} diff --git a/pmocontrol/src/avtransport_client.rs b/pmocontrol/src/avtransport_client.rs new file mode 100644 index 00000000..a3174510 --- /dev/null +++ b/pmocontrol/src/avtransport_client.rs @@ -0,0 +1,128 @@ +use anyhow::{anyhow, Result}; +use crate::soap_client::invoke_upnp_action; +use pmoupnp::soap::SoapEnvelope; +use xmltree::{Element, XMLNode}; + +#[derive(Debug, Clone)] +pub struct AvTransportClient { + pub control_url: String, + pub service_type: String, +} + +#[derive(Debug, Clone)] +pub struct TransportInfo { + pub current_transport_state: String, + pub current_transport_status: String, + pub current_speed: String, +} + +impl AvTransportClient { + pub fn new(control_url: String, service_type: String) -> Self { + Self { + control_url, + service_type, + } + } + + 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())]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetTransportInfo", + &args, + )?; + + if !call_result.status.is_success() { + return Err(anyhow!( + "GetTransportInfo failed with HTTP status {}", + call_result.status + )); + } + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetTransportInfo response"))?; + + parse_transport_info(envelope) + } +} + +fn parse_transport_info(envelope: &SoapEnvelope) -> Result { + let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse") + .ok_or_else(|| anyhow!("Missing GetTransportInfoResponse element in SOAP body"))?; + + let current_transport_state = + extract_child_text(response, "CurrentTransportState")?; + let current_transport_status = + extract_child_text(response, "CurrentTransportStatus")?; + let current_speed = extract_child_text(response, "CurrentSpeed")?; + + Ok(TransportInfo { + current_transport_state, + current_transport_status, + current_speed, + }) +} + +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 GetTransportInfoResponse"))?; + + 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 GetTransportInfoResponse"))?; + + Ok(text) +} + +#[cfg(test)] +mod 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_transport_info_extracts_fields() { + let mut response = Element::new("u:GetTransportInfoResponse"); + response.children.push(XMLNode::Element(text_element( + "CurrentTransportState", + "STOPPED", + ))); + response.children.push(XMLNode::Element(text_element( + "CurrentTransportStatus", + "OK", + ))); + response.children.push(XMLNode::Element(text_element("CurrentSpeed", "1"))); + + let mut body = Element::new("s:Body"); + body.children.push(XMLNode::Element(response)); + + let envelope = SoapEnvelope { + header: None, + body: SoapBody { content: body }, + }; + + let info = parse_transport_info(&envelope).unwrap(); + assert_eq!(info.current_transport_state, "STOPPED"); + assert_eq!(info.current_transport_status, "OK"); + assert_eq!(info.current_speed, "1"); + } +} diff --git a/pmocontrol/src/discovery.rs b/pmocontrol/src/discovery.rs index 12652bb8..29934723 100644 --- a/pmocontrol/src/discovery.rs +++ b/pmocontrol/src/discovery.rs @@ -172,15 +172,12 @@ where max_age ); - let endpoint = self - .endpoints - .entry(udn.clone()) - .or_insert_with({ - let udn = udn.clone(); - let location = location.clone(); - let server_header = server_header.clone(); - move || DiscoveredEndpoint::new(udn, location, server_header, max_age) - }); + let endpoint = self.endpoints.entry(udn.clone()).or_insert_with({ + let udn = udn.clone(); + let location = location.clone(); + let server_header = server_header.clone(); + move || DiscoveredEndpoint::new(udn, location, server_header, max_age) + }); endpoint.touch(location, server_header, max_age); endpoint.types_seen.insert(device_type); diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 35bebe3e..39acd3e8 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -1,10 +1,12 @@ +pub mod avtransport_client; pub mod control_point; -pub mod soap_client; pub mod discovery; pub mod model; pub mod provider; pub mod registry; +pub mod soap_client; +pub use avtransport_client::{AvTransportClient, TransportInfo}; pub use control_point::ControlPoint; pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager}; @@ -16,7 +18,3 @@ pub use provider::HttpXmlDescriptionProvider; pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; pub use soap_client::invoke_upnp_action; - - - - diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index f327cee4..13f2c177 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -40,6 +40,9 @@ pub struct RendererInfo { pub online: bool, pub last_seen: std::time::SystemTime, pub max_age: u32, + + pub avtransport_service_type: Option, + pub avtransport_control_url: Option, } #[derive(Clone, Debug, Default)] diff --git a/pmocontrol/src/provider.rs b/pmocontrol/src/provider.rs index 62ba62af..8da05831 100644 --- a/pmocontrol/src/provider.rs +++ b/pmocontrol/src/provider.rs @@ -1,11 +1,12 @@ use std::io::BufReader; -use std::time::{Duration, SystemTime}; +use std::time::SystemTime; use quick_xml::{Error as XmlError, Reader, events::Event}; use thiserror::Error; use tracing::{debug, warn}; -use crate::discovery::DiscoveredEndpoint; +use crate::avtransport_client::AvTransportClient; +use crate::discovery::{DeviceDescriptionProvider, DiscoveredEndpoint}; use crate::model::{ MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId, RendererInfo, RendererProtocol, @@ -28,6 +29,7 @@ pub enum DescriptionError { MissingField(&'static str), } +/// Parsed device description, plus (optionally) AVTransport endpoint. #[derive(Debug, Default)] struct ParsedDeviceDescription { udn: Option, @@ -36,6 +38,10 @@ struct ParsedDeviceDescription { manufacturer: Option, model_name: Option, service_types: Vec, + + // New: AVTransport endpoint (if present in serviceList) + avtransport_service_type: Option, + avtransport_control_url: Option, } impl ParsedDeviceDescription { @@ -53,6 +59,7 @@ impl ParsedDeviceDescription { } } +/// HTTP-based XML description provider (UPnP device description.xml) pub struct HttpXmlDescriptionProvider { timeout_secs: u64, } @@ -62,6 +69,7 @@ impl HttpXmlDescriptionProvider { Self { timeout_secs } } + /// Fetch and parse the device description.xml at endpoint.location. fn fetch_and_parse( &self, endpoint: &DiscoveredEndpoint, @@ -72,7 +80,7 @@ impl HttpXmlDescriptionProvider { ); let config = Agent::config_builder() - .timeout_global(Some(Duration::from_secs(self.timeout_secs))) + .timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs))) .build(); let agent: Agent = config.into(); @@ -99,6 +107,10 @@ impl HttpXmlDescriptionProvider { let mut in_service = false; let mut current_tag: Option = None; + // New: track current serviceType + controlURL while inside ... + let mut current_service_type: Option = None; + let mut current_control_url: Option = None; + loop { match reader.read_event_into(&mut buf)? { Event::Start(e) => { @@ -112,6 +124,8 @@ impl HttpXmlDescriptionProvider { if in_device { in_service = true; current_tag = None; + current_service_type = None; + current_control_url = None; } } _ => { @@ -128,7 +142,31 @@ impl HttpXmlDescriptionProvider { in_device = false; } "service" => { - in_service = false; + if in_device && in_service { + // We just finished a block: if this is AVTransport, + // store its endpoint in parsed.* + if let (Some(st), Some(ctrl)) = + (¤t_service_type, ¤t_control_url) + { + let lower = st.to_ascii_lowercase(); + if lower.contains("urn:schemas-upnp-org:service:avtransport:") { + // Only set once; if multiple AVTransport services exist, + // we keep the first one. + if parsed.avtransport_service_type.is_none() { + parsed.avtransport_service_type = Some(st.clone()); + parsed.avtransport_control_url = Some(ctrl.clone()); + debug!( + "Found AVTransport service for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } + } + + in_service = false; + current_service_type = None; + current_control_url = None; + } } _ => {} } @@ -138,10 +176,7 @@ impl HttpXmlDescriptionProvider { if in_device { if let Some(tag) = ¤t_tag { // quick-xml ≥ 0.37 : unescape() → decode() - let text = e - .decode() // Result, EncodingError> - .map_err(XmlError::Encoding)? // -> quick_xml::Error, donc DescriptionError::Xml via #[from] - .into_owned(); // String + let text = e.decode().map_err(XmlError::Encoding)?.into_owned(); match tag.as_str() { "UDN" => { @@ -160,7 +195,11 @@ impl HttpXmlDescriptionProvider { parsed.model_name = Some(text); } "serviceType" if in_service => { - parsed.service_types.push(text); + parsed.service_types.push(text.clone()); + current_service_type = Some(text); + } + "controlURL" if in_service => { + current_control_url = Some(text); } _ => {} } @@ -216,6 +255,11 @@ impl HttpXmlDescriptionProvider { online: true, last_seen: now, max_age: endpoint.max_age, + avtransport_service_type: parsed.avtransport_service_type.clone(), + avtransport_control_url: parsed + .avtransport_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), }) } @@ -251,8 +295,39 @@ impl HttpXmlDescriptionProvider { max_age: endpoint.max_age, }) } + + /// New helper: build an AvTransportClient directly from a discovered endpoint. + /// + /// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present, + /// Ok(None) if no AVTransport service was found. + pub fn build_avtransport_client( + &self, + endpoint: &DiscoveredEndpoint, + ) -> Result, DescriptionError> { + let parsed = self.fetch_and_parse(endpoint)?; + + let service_type = match &parsed.avtransport_service_type { + Some(st) => st.clone(), + None => return Ok(None), + }; + + let raw_control = match &parsed.avtransport_control_url { + Some(ctrl) => ctrl.clone(), + None => return Ok(None), + }; + + let control_url = resolve_control_url(&endpoint.location, &raw_control); + debug!( + "AVTransport client for {}: service_type={} control_url={}", + endpoint.udn, service_type, control_url + ); + + Ok(Some(AvTransportClient::new(control_url, service_type))) + } } +// --- capabilities detection unchanged --- + fn detect_renderer_capabilities(service_types: &[String]) -> RendererCapabilities { let mut caps = RendererCapabilities::default(); @@ -321,7 +396,32 @@ fn detect_server_capabilities(service_types: &[String]) -> MediaServerCapabiliti caps } -use crate::discovery::DeviceDescriptionProvider; +/// Resolve a possibly relative controlURL against the description URL. +/// +/// - If `control_url` is already absolute (starts with http:// or https://), it is returned as-is. +/// - Otherwise, it is resolved against the scheme://host:port of `description_url`. +fn resolve_control_url(description_url: &str, control_url: &str) -> String { + if control_url.starts_with("http://") || control_url.starts_with("https://") { + return control_url.to_string(); + } + + // Extract "scheme://host[:port]" from description_url + if let Some((scheme, rest)) = description_url.split_once("://") { + if let Some(pos) = rest.find('/') { + let authority = &rest[..pos]; + let base = format!("{}://{}", scheme, authority); + + if control_url.starts_with('/') { + return format!("{}{}", base, control_url); + } else { + return format!("{}/{}", base, control_url); + } + } + } + + // Fallback: just return the raw control_url if we cannot parse + control_url.to_string() +} impl DeviceDescriptionProvider for HttpXmlDescriptionProvider { fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option { @@ -363,4 +463,4 @@ impl DeviceDescriptionProvider for HttpXmlDescriptionProvider { } } } -} +} \ No newline at end of file diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs index 8d3d47e4..d618bb6f 100644 --- a/pmocontrol/src/registry.rs +++ b/pmocontrol/src/registry.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::time::SystemTime; +use crate::avtransport_client::AvTransportClient; use crate::model::{MediaServerId, MediaServerInfo, RendererId, RendererInfo}; #[derive(Clone, Debug)] @@ -16,6 +17,10 @@ pub struct DeviceRegistry { udn_index: HashMap, } +/// Read-only view / trait for registry access. +/// +/// Pour l’instant, on ne rajoute pas AVTransport ici, on se contente +/// d’ajouter les helpers dans `impl DeviceRegistry`. pub trait DeviceRegistryRead { fn list_renderers(&self) -> Vec; fn list_servers(&self) -> Vec; @@ -114,4 +119,43 @@ impl DeviceRegistry { } } } + + /// Helper: get a renderer by UDN (case-insensitive, via udn_index). + pub fn get_renderer_by_udn(&self, udn: &str) -> Option { + let lookup = udn.to_ascii_lowercase(); + match self.udn_index.get(&lookup) { + Some(DeviceKey::Renderer(id)) => self.renderers.get(id).cloned(), + _ => None, + } + } + + /// Construct an AvTransportClient for a given renderer id, if possible. + /// + /// Returns: + /// - Some(client) if the renderer exists AND has avtransport_* fields set + /// - None if renderer not found or no AVTransport service. + pub fn avtransport_client_for_renderer( + &self, + id: &RendererId, + ) -> Option { + let info = self.renderers.get(id)?; + + let service_type = info.avtransport_service_type.as_ref()?; + let control_url = info.avtransport_control_url.as_ref()?; + + Some(AvTransportClient::new( + control_url.clone(), + service_type.clone(), + )) + } + + /// Construct an AvTransportClient for a given UDN, if possible. + pub fn avtransport_client_for_udn(&self, udn: &str) -> Option { + let info = self.get_renderer_by_udn(udn)?; + + let service_type = info.avtransport_service_type?; + let control_url = info.avtransport_control_url?; + + Some(AvTransportClient::new(control_url, service_type)) + } } diff --git a/pmocontrol/src/soap_client.rs b/pmocontrol/src/soap_client.rs index 9d8dc3ad..ef5d4df8 100644 --- a/pmocontrol/src/soap_client.rs +++ b/pmocontrol/src/soap_client.rs @@ -15,37 +15,31 @@ pub struct SoapCallResult { /// Invoke a UPnP SOAP action on a control URL. /// /// - `control_url`: full HTTP URL of the service control endpoint -/// - `service_type`: service URN, e.g. "urn:schemas-upnp-org:service:AVTransport:1" -/// - `action`: action name, e.g. "GetTransportInfo" -/// - `args`: list of (name, value) pairs, e.g. &[("InstanceID", "0")] +/// - `service_type`: service URN +/// - `action`: action name +/// - `args`: list of (name, value) pub fn invoke_upnp_action( control_url: &str, service_type: &str, action: &str, args: &[(&str, &str)], ) -> Result { - // 1. Build SOAP request body using pmoupnp::soap + + // 1. Build SOAP request XML using pmoupnp let body_xml = build_soap_request(service_type, action, args) .context("Failed to build SOAP request body")?; - // 2. Build an Agent config that does NOT treat 4xx/5xx as errors. - // - // This is crucial: we want to be able to read the body even for - // HTTP 500 SOAP Faults, so we must *not* get Error::StatusCode. + // 2. Build agent that does NOT treat 4xx/5xx as errors let config = Agent::config_builder() .http_status_as_error(false) .build(); let agent: Agent = config.into(); - // 3. Build SOAPAction header: "urn:service#Action" + // 3. SOAPAction header let soap_action_header = format!(r#""{}#{}""#, service_type, action); - // 4. Send HTTP POST request - // - // - RequestBuilder::header(...) is the proper 3.x API. - // - RequestBuilder::send(...) accepts anything implementing AsSendBody, - // including `String`. + // 4. HTTP POST let mut response = agent .post(control_url) .header("Content-Type", r#"text/xml; charset="utf-8""#) @@ -55,21 +49,16 @@ pub fn invoke_upnp_action( let status = response.status(); - // 5. Read full body into a String, regardless of HTTP status code. + // 5. Read full body // - // This matches the pattern you already use for description.xml: - // response.body_mut().read_to_string(...) - let raw_body = String::new(); - response + // API réelle (ureq 3.1.4): + // body_mut().read_to_string() -> Result + let raw_body = response .body_mut() .read_to_string() .context("Failed to read SOAP response body")?; - // 6. Try to parse SOAP envelope. - // - // We *do not* fail the whole call if parsing fails: - // - you still get status + raw_body, - // - envelope is None if body is not valid SOAP. + // 6. Try to parse SOAP envelope; non-fatal on failure let envelope = match parse_soap_envelope(raw_body.as_bytes()) { Ok(env) => Some(env), Err(_) => None,