From 3ec692ea6f995140b213bcdda24cb209d74476ba Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 29 Nov 2025 18:56:11 +0100 Subject: [PATCH] =?UTF-8?q?=20impl=C3=A9mentation=20concr=C3=A8te=20de=20D?= =?UTF-8?q?eviceDescriptionProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 14340 -> 14340 bytes Cargo.lock | 53 +++++ pmocontrol/Cargo.toml | 6 + pmocontrol/examples/discover.rs | 52 +++++ pmocontrol/examples/dump_ssdp.rs | 59 +++++ pmocontrol/src/control_point.rs | 79 +++++++ pmocontrol/src/discovery.rs | 40 ++-- pmocontrol/src/lib.rs | 12 + pmocontrol/src/provider.rs | 366 +++++++++++++++++++++++++++++++ pmocontrol/src/soap_client.rs | 83 +++++++ pmoupnp/Cargo.toml | 3 +- pmoupnp/src/soap/builder.rs | 71 +++--- pmoupnp/src/soap/mod.rs | 4 +- pmoupnp/src/ssdp/client.rs | 73 +++--- 14 files changed, 830 insertions(+), 71 deletions(-) create mode 100644 pmocontrol/examples/discover.rs create mode 100644 pmocontrol/examples/dump_ssdp.rs create mode 100644 pmocontrol/src/control_point.rs create mode 100644 pmocontrol/src/provider.rs create mode 100644 pmocontrol/src/soap_client.rs diff --git a/.DS_Store b/.DS_Store index 15627691ae7eb9406113b7611e969f05c860a898..8a655fcc4113a688d3ba6a76de261bf3bcade2fa 100644 GIT binary patch delta 38 ucmZoEXepTB<KfV7EM@-ew+!*^HZ%n0Ys^l8a^B%x>_FWpkJ+J2Lt`m#DBa0{~f{83_OY diff --git a/Cargo.lock b/Cargo.lock index 82818044..e3fc36b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3162,7 +3162,13 @@ dependencies = [ name = "pmocontrol" version = "0.1.0" dependencies = [ + "anyhow", "pmoupnp", + "quick-xml 0.38.4", + "thiserror 2.0.17", + "tracing", + "tracing-subscriber", + "ureq", ] [[package]] @@ -3427,6 +3433,7 @@ dependencies = [ "bevy_reflect", "bevy_reflect_derive", "chrono", + "get_if_addrs", "hex", "libc", "once_cell", @@ -3999,7 +4006,9 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -5117,6 +5126,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.7" @@ -5139,6 +5177,12 @@ dependencies = [ "log", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5377,6 +5421,15 @@ dependencies = [ "libwebp-sys", ] +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index db0beee0..ee2b43a3 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -5,3 +5,9 @@ edition = "2024" [dependencies] pmoupnp = { path = "../pmoupnp" } +quick-xml = "0.38.4" +thiserror = "2.0.17" +ureq = "3.1.4" +tracing = "0.1.41" +tracing-subscriber = "0.3" +anyhow = "1.0" diff --git a/pmocontrol/examples/discover.rs b/pmocontrol/examples/discover.rs new file mode 100644 index 00000000..e1fbfe32 --- /dev/null +++ b/pmocontrol/examples/discover.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use pmocontrol::{ControlPoint, DeviceRegistryRead, MediaServerInfo, RendererInfo}; +use pmocontrol::RendererProtocol; + +fn main() -> std::io::Result<()> { + // Un tout petit logging optionnel + tracing_subscriber::fmt::init(); + tracing::info!("Starting PMOMusic control point SSDP discovery example..."); + + // Lance le control point (timeout HTTP pour les descriptions UPnP) + let cp = ControlPoint::spawn(5)?; + + loop { + thread::sleep(Duration::from_secs(5)); + + // Accès thread-safe au DeviceRegistry + let reg = cp.registry(); + let reg = reg.read().expect("registry poisoned"); + + let renderers: Vec = reg.list_renderers(); + let servers: Vec = reg.list_servers(); + + println!("====================="); + println!("Renderers detected : {}", renderers.len()); + for r in &renderers { + let proto = match r.protocol { + RendererProtocol::UpnpAvOnly => "UPnP AV", + RendererProtocol::OpenHomeOnly => "OpenHome", + RendererProtocol::Hybrid => "Hybrid", + }; + + println!( + "- [{}] {} ({}) [{}] online={}", + r.id.0, r.friendly_name, r.model_name, proto, r.online + ); + } + + println!(); + println!("Media servers detected : {}", servers.len()); + for s in &servers { + println!( + "- [{}] {} ({}) online={}", + s.id.0, s.friendly_name, s.model_name, s.online + ); + } + + println!("====================="); + } +} diff --git a/pmocontrol/examples/dump_ssdp.rs b/pmocontrol/examples/dump_ssdp.rs new file mode 100644 index 00000000..5e1bf19d --- /dev/null +++ b/pmocontrol/examples/dump_ssdp.rs @@ -0,0 +1,59 @@ +use std::thread; +use std::time::Duration; + +use pmoupnp::ssdp::{SsdpClient, SsdpEvent}; + +fn main() -> std::io::Result<()> { + tracing_subscriber::fmt::init(); + tracing::info!("Starting raw SSDP dump helper..."); + + let client = SsdpClient::new()?; + + // Envoie quelques requêtes M-SEARCH ciblées pour accélérer les réponses. + let search_targets = [ + "ssdp:all", + "urn:schemas-upnp-org:device:MediaRenderer:1", + "urn:av-openhome-org:device:MediaRenderer:1", + "urn:schemas-upnp-org:device:MediaServer:1", + ]; + for st in &search_targets { + if let Err(err) = client.send_msearch(st, 3) { + eprintln!("Failed to send M-SEARCH for {}: {}", st, err); + } + thread::sleep(Duration::from_millis(200)); + } + + println!("Listening for SSDP events. Press Ctrl+C to stop."); + + client.run_event_loop(|event| match event { + SsdpEvent::Alive { + usn, + nt, + location, + server, + max_age, + from, + } => { + println!( + "[ALIVE] from={} usn={} nt={} location={} server={} max_age={}", + from, usn, nt, location, server, max_age + ); + } + SsdpEvent::SearchResponse { + usn, + st, + location, + server, + max_age, + from, + } => { + println!( + "[SEARCH RESPONSE] from={} usn={} st={} location={} server={} max_age={}", + from, usn, st, location, server, max_age + ); + } + SsdpEvent::ByeBye { usn, nt, from } => { + println!("[BYEBYE] from={} usn={} nt={}", from, usn, nt); + } + }) +} diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs new file mode 100644 index 00000000..35a4b472 --- /dev/null +++ b/pmocontrol/src/control_point.rs @@ -0,0 +1,79 @@ +use std::io; +use std::sync::{Arc, RwLock}; +use std::thread; +use std::time::Duration; + +use pmoupnp::ssdp::SsdpClient; + +use crate::discovery::DiscoveryManager; +use crate::provider::HttpXmlDescriptionProvider; +use crate::registry::{DeviceRegistry, DeviceUpdate}; + +/// Control point minimal : +/// - lance un SsdpClient dans un thread, +/// - passe les SsdpEvent au DiscoveryManager, +/// - applique les DeviceUpdate dans le DeviceRegistry. +pub struct ControlPoint { + registry: Arc>, +} + +impl ControlPoint { + /// Crée un ControlPoint et lance le thread de découverte SSDP. + /// + /// `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())); + + // SsdpClient + let client = SsdpClient::new()?; // pmoupnp::ssdp::SsdpClient + + // Arc utilisé dans le thread + let registry_for_thread = Arc::clone(®istry); + + // Thread de découverte + thread::spawn(move || { + // Provider HTTP+XML et DiscoveryManager VIVENT dans le thread + let provider = HttpXmlDescriptionProvider::new(timeout_secs); + let mut discovery = DiscoveryManager::new(provider); + + // ACTIVE DISCOVERY : envoyer quelques M-SEARCH au démarrage + // pour forcer les devices à répondre rapidement. + let search_targets = [ + "ssdp:all", + "urn:schemas-upnp-org:device:MediaRenderer:1", + "urn:av-openhome-org:device:MediaRenderer:1", + "urn:schemas-upnp-org:device:MediaServer:1", + ]; + + for st in &search_targets { + if let Err(e) = client.send_msearch(st, 3) { + eprintln!("Failed to send M-SEARCH for {}: {}", st, e); + } + std::thread::sleep(Duration::from_millis(200)); + } + + // La closure passée à run_event_loop capture discovery par mutable borrow + // => FnMut, ce que SsdpClient::run_event_loop accepte. + client.run_event_loop(move |event| { + let updates: Vec = discovery.handle_ssdp_event(event); + + if updates.is_empty() { + return; + } + + if let Ok(mut reg) = registry_for_thread.write() { + for update in updates { + reg.apply_update(update); + } + } + }); + }); + + Ok(Self { registry }) + } + + /// Accès au DeviceRegistry partagé. + pub fn registry(&self) -> Arc> { + Arc::clone(&self.registry) + } +} diff --git a/pmocontrol/src/discovery.rs b/pmocontrol/src/discovery.rs index ce2f93f1..12652bb8 100644 --- a/pmocontrol/src/discovery.rs +++ b/pmocontrol/src/discovery.rs @@ -164,6 +164,14 @@ where max_age: u32, updates: &mut Vec, ) { + tracing::debug!( + "SSDP update: udn={} type={} location={} max_age={}", + udn, + device_type, + location, + max_age + ); + let endpoint = self .endpoints .entry(udn.clone()) @@ -175,18 +183,30 @@ where }); endpoint.touch(location, server_header, max_age); - endpoint.types_seen.insert(device_type.clone()); + endpoint.types_seen.insert(device_type); - if is_renderer_type(&device_type) { - endpoint.seen_as_renderer = true; + if !endpoint.seen_as_renderer { if let Some(info) = self.provider.build_renderer_info(endpoint) { + tracing::debug!( + "Renderer classified: udn={} friendly_name={} model={}", + info.udn, + info.friendly_name, + info.model_name + ); + endpoint.seen_as_renderer = true; updates.push(DeviceUpdate::RendererOnline(info)); } } - if is_server_type(&device_type) { - endpoint.seen_as_server = true; + if !endpoint.seen_as_server { if let Some(info) = self.provider.build_server_info(endpoint) { + tracing::debug!( + "Server classified: udn={} friendly_name={} model={}", + info.udn, + info.friendly_name, + info.model_name + ); + endpoint.seen_as_server = true; updates.push(DeviceUpdate::ServerOnline(info)); } } @@ -206,13 +226,3 @@ fn extract_udn_from_usn(usn: &str) -> Option { None } } - -fn is_renderer_type(t: &str) -> bool { - let t = t.to_ascii_lowercase(); - t.contains("urn:schemas-upnp-org:device:mediarenderer:") -} - -fn is_server_type(t: &str) -> bool { - let t = t.to_ascii_lowercase(); - t.contains("urn:schemas-upnp-org:device:mediaserver:") -} diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 51255f96..35bebe3e 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -1,10 +1,22 @@ +pub mod control_point; +pub mod soap_client; pub mod discovery; pub mod model; +pub mod provider; pub mod registry; +pub use control_point::ControlPoint; + pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager}; pub use model::{ MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId, RendererInfo, RendererProtocol, }; +pub use provider::HttpXmlDescriptionProvider; pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; + +pub use soap_client::invoke_upnp_action; + + + + diff --git a/pmocontrol/src/provider.rs b/pmocontrol/src/provider.rs new file mode 100644 index 00000000..62ba62af --- /dev/null +++ b/pmocontrol/src/provider.rs @@ -0,0 +1,366 @@ +use std::io::BufReader; +use std::time::{Duration, SystemTime}; + +use quick_xml::{Error as XmlError, Reader, events::Event}; +use thiserror::Error; +use tracing::{debug, warn}; + +use crate::discovery::DiscoveredEndpoint; +use crate::model::{ + MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId, + RendererInfo, RendererProtocol, +}; + +use ureq::Agent; + +#[derive(Debug, Error)] +pub enum DescriptionError { + #[error("HTTP request failed: {0}")] + Http(#[from] ureq::Error), + + #[error("Failed to read HTTP body: {0}")] + HttpIo(#[from] std::io::Error), + + #[error("XML parsing error: {0}")] + Xml(#[from] quick_xml::Error), + + #[error("Missing required device element: {0}")] + MissingField(&'static str), +} + +#[derive(Debug, Default)] +struct ParsedDeviceDescription { + udn: Option, + device_type: Option, + friendly_name: Option, + manufacturer: Option, + model_name: Option, + service_types: Vec, +} + +impl ParsedDeviceDescription { + fn require_fields(self) -> Result { + if self.device_type.is_none() { + return Err(DescriptionError::MissingField("deviceType")); + } + if self.friendly_name.is_none() { + return Err(DescriptionError::MissingField("friendlyName")); + } + if self.model_name.is_none() { + return Err(DescriptionError::MissingField("modelName")); + } + Ok(self) + } +} + +pub struct HttpXmlDescriptionProvider { + timeout_secs: u64, +} + +impl HttpXmlDescriptionProvider { + pub fn new(timeout_secs: u64) -> Self { + Self { timeout_secs } + } + + fn fetch_and_parse( + &self, + endpoint: &DiscoveredEndpoint, + ) -> Result { + debug!( + "Fetching description for {} at {}", + endpoint.udn, endpoint.location + ); + + let config = Agent::config_builder() + .timeout_global(Some(Duration::from_secs(self.timeout_secs))) + .build(); + + let agent: Agent = config.into(); + + let response = agent.get(&endpoint.location).call()?; + + // response: http::Response + let (_parts, body) = response.into_parts(); + + // body.into_reader() -> impl Read + 'static + let body_reader = body.into_reader(); + + let mut reader = Reader::from_reader(BufReader::new(body_reader)); + reader.config_mut().trim_text(true); + debug!( + "Parsing description XML for {} at {}", + endpoint.udn, endpoint.location + ); + + let mut buf = Vec::new(); + let mut parsed = ParsedDeviceDescription::default(); + + let mut in_device = false; + let mut in_service = false; + let mut current_tag: Option = None; + + loop { + match reader.read_event_into(&mut buf)? { + Event::Start(e) => { + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + match name.as_str() { + "device" => { + in_device = true; + current_tag = None; + } + "service" => { + if in_device { + in_service = true; + current_tag = None; + } + } + _ => { + if in_device { + current_tag = Some(name); + } + } + } + } + Event::End(e) => { + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + match name.as_str() { + "device" => { + in_device = false; + } + "service" => { + in_service = false; + } + _ => {} + } + current_tag = None; + } + Event::Text(e) => { + 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 + + match tag.as_str() { + "UDN" => { + parsed.udn = Some(text); + } + "deviceType" => { + parsed.device_type = Some(text); + } + "friendlyName" => { + parsed.friendly_name = Some(text); + } + "manufacturer" => { + parsed.manufacturer = Some(text); + } + "modelName" => { + parsed.model_name = Some(text); + } + "serviceType" if in_service => { + parsed.service_types.push(text); + } + _ => {} + } + } + } + } + Event::Eof => break, + _ => {} + } + + buf.clear(); + } + + parsed.require_fields() + } + + fn build_renderer( + &self, + endpoint: &DiscoveredEndpoint, + parsed: &ParsedDeviceDescription, + ) -> Option { + let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase(); + if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:") + && !device_type.contains("urn:av-openhome-org:device:mediarenderer:") + && !device_type.contains("urn:av-openhome-org:device:source:") + { + debug!( + "build_renderer: ignoring deviceType for {}: {}", + endpoint.udn, device_type + ); + return None; + } + + let raw_udn = parsed + .udn + .as_deref() + .unwrap_or_else(|| endpoint.udn.as_str()); + let udn = raw_udn.to_ascii_lowercase(); + let caps = detect_renderer_capabilities(&parsed.service_types); + let protocol = detect_renderer_protocol(&caps); + let now = SystemTime::now(); + + Some(RendererInfo { + id: RendererId(udn.clone()), + udn, + friendly_name: parsed.friendly_name.clone().unwrap_or_default(), + model_name: parsed.model_name.clone().unwrap_or_default(), + manufacturer: parsed.manufacturer.clone().unwrap_or_default(), + protocol, + capabilities: caps, + location: endpoint.location.clone(), + server_header: endpoint.server_header.clone(), + online: true, + last_seen: now, + max_age: endpoint.max_age, + }) + } + + fn build_server( + &self, + endpoint: &DiscoveredEndpoint, + parsed: &ParsedDeviceDescription, + ) -> Option { + let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase(); + if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") { + return None; + } + + let raw_udn = parsed + .udn + .as_deref() + .unwrap_or_else(|| endpoint.udn.as_str()); + let udn = raw_udn.to_ascii_lowercase(); + let caps = detect_server_capabilities(&parsed.service_types); + let now = SystemTime::now(); + + Some(MediaServerInfo { + id: MediaServerId(udn.clone()), + udn, + friendly_name: parsed.friendly_name.clone().unwrap_or_default(), + model_name: parsed.model_name.clone().unwrap_or_default(), + manufacturer: parsed.manufacturer.clone().unwrap_or_default(), + capabilities: caps, + location: endpoint.location.clone(), + server_header: endpoint.server_header.clone(), + online: true, + last_seen: now, + max_age: endpoint.max_age, + }) + } +} + +fn detect_renderer_capabilities(service_types: &[String]) -> RendererCapabilities { + let mut caps = RendererCapabilities::default(); + + for st in service_types { + let lower = st.to_ascii_lowercase(); + + if lower.contains("urn:schemas-upnp-org:service:avtransport:") { + caps.has_avtransport = true; + } + if lower.contains("urn:schemas-upnp-org:service:renderingcontrol:") { + caps.has_rendering_control = true; + } + if lower.contains("urn:schemas-upnp-org:service:connectionmanager:") { + caps.has_connection_manager = true; + } + if lower.contains("urn:av-openhome-org:service:playlist:") { + caps.has_oh_playlist = true; + } + if lower.contains("urn:av-openhome-org:service:volume:") { + caps.has_oh_volume = true; + } + if lower.contains("urn:av-openhome-org:service:info:") { + caps.has_oh_info = true; + } + if lower.contains("urn:av-openhome-org:service:time:") { + caps.has_oh_time = true; + } + if lower.contains("urn:av-openhome-org:service:radio:") { + caps.has_oh_radio = true; + } + } + + caps +} + +fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol { + let has_upnp_av = + caps.has_avtransport || caps.has_rendering_control || caps.has_connection_manager; + let has_openhome = caps.has_oh_playlist + || caps.has_oh_volume + || caps.has_oh_info + || caps.has_oh_time + || caps.has_oh_radio; + + match (has_upnp_av, has_openhome) { + (true, true) => RendererProtocol::Hybrid, + (true, false) => RendererProtocol::UpnpAvOnly, + (false, true) => RendererProtocol::OpenHomeOnly, + (false, false) => RendererProtocol::UpnpAvOnly, + } +} + +fn detect_server_capabilities(service_types: &[String]) -> MediaServerCapabilities { + let mut caps = MediaServerCapabilities::default(); + + for st in service_types { + let lower = st.to_ascii_lowercase(); + if lower.contains("urn:schemas-upnp-org:service:contentdirectory:") { + caps.has_content_directory = true; + } + if lower.contains("urn:schemas-upnp-org:service:connectionmanager:") { + caps.has_connection_manager = true; + } + } + + caps +} + +use crate::discovery::DeviceDescriptionProvider; + +impl DeviceDescriptionProvider for HttpXmlDescriptionProvider { + fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option { + match self.fetch_and_parse(endpoint) { + Ok(parsed) => { + let device_type = parsed.device_type.as_deref().unwrap_or("unknown"); + debug!( + "Renderer description OK for {} at {} (deviceType={})", + endpoint.udn, endpoint.location, device_type + ); + self.build_renderer(endpoint, &parsed) + } + Err(err) => { + warn!( + "Failed to fetch/parse renderer description for {} at {}: {}", + endpoint.udn, endpoint.location, err + ); + None + } + } + } + + fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option { + match self.fetch_and_parse(endpoint) { + Ok(parsed) => { + let device_type = parsed.device_type.as_deref().unwrap_or("unknown"); + debug!( + "Server description OK for {} at {} (deviceType={})", + endpoint.udn, endpoint.location, device_type + ); + self.build_server(endpoint, &parsed) + } + Err(err) => { + warn!( + "Failed to fetch/parse server description for {} at {}: {}", + endpoint.udn, endpoint.location, err + ); + None + } + } + } +} diff --git a/pmocontrol/src/soap_client.rs b/pmocontrol/src/soap_client.rs new file mode 100644 index 00000000..9d8dc3ad --- /dev/null +++ b/pmocontrol/src/soap_client.rs @@ -0,0 +1,83 @@ +use anyhow::{Context, Result}; +use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope}; +use ureq::Agent; + +/// Result of a SOAP call: +/// - HTTP status code +/// - raw XML body (always) +/// - parsed SOAP envelope if parsing succeeded +pub struct SoapCallResult { + pub status: ureq::http::StatusCode, + pub raw_body: String, + pub envelope: Option, +} + +/// 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")] +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 + 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. + let config = Agent::config_builder() + .http_status_as_error(false) + .build(); + + let agent: Agent = config.into(); + + // 3. Build SOAPAction header: "urn:service#Action" + 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`. + let mut response = agent + .post(control_url) + .header("Content-Type", r#"text/xml; charset="utf-8""#) + .header("SOAPAction", &soap_action_header) + .send(body_xml) + .with_context(|| format!("HTTP error when sending SOAP request to {}", control_url))?; + + let status = response.status(); + + // 5. Read full body into a String, regardless of HTTP status code. + // + // This matches the pattern you already use for description.xml: + // response.body_mut().read_to_string(...) + let raw_body = String::new(); + 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. + let envelope = match parse_soap_envelope(raw_body.as_bytes()) { + Ok(env) => Some(env), + Err(_) => None, + }; + + Ok(SoapCallResult { + status, + raw_body, + envelope, + }) +} diff --git a/pmoupnp/Cargo.toml b/pmoupnp/Cargo.toml index 29d98e40..9f41ffd1 100644 --- a/pmoupnp/Cargo.toml +++ b/pmoupnp/Cargo.toml @@ -24,7 +24,7 @@ axum = "0.8.4" tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -quick-xml = { version = "0.37.0", features = ["serialize"] } +quick-xml = { version = "0.37.5", features = ["serialize"] } chrono = { version = "0.4.42", features = ["serde"] } once_cell = "1.20" parking_lot = "0.12" @@ -35,6 +35,7 @@ bevy_reflect_derive = "0.17.1" reqwest = "0.12.23" utoipa = { version = "5.3", features = ["axum_extras"] } socket2 = "0.5" +get_if_addrs = "0.5" [features] default = ["server"] diff --git a/pmoupnp/src/soap/builder.rs b/pmoupnp/src/soap/builder.rs index 6c760f6a..8d952818 100644 --- a/pmoupnp/src/soap/builder.rs +++ b/pmoupnp/src/soap/builder.rs @@ -2,6 +2,34 @@ use xmltree::{Element, XMLNode}; +fn build_soap_envelope_with_body(body_child: Element) -> Result { + // Body + let mut body = Element::new("s:Body"); + body.children.push(XMLNode::Element(body_child)); + + // Envelope + let mut envelope = Element::new("s:Envelope"); + envelope.attributes.insert( + "xmlns:s".to_string(), + "http://schemas.xmlsoap.org/soap/envelope/".to_string(), + ); + envelope.attributes.insert( + "s:encodingStyle".to_string(), + "http://schemas.xmlsoap.org/soap/encoding/".to_string(), + ); + envelope.children.push(XMLNode::Element(body)); + + let mut buf = Vec::new(); + let config = xmltree::EmitterConfig::new() + .write_document_declaration(true) + .perform_indent(true) + .indent_string(" "); + envelope.write_with_config(&mut buf, config)?; + + Ok(String::from_utf8(buf).unwrap()) +} + + /// Construit une réponse SOAP UPnP /// /// # Arguments @@ -18,46 +46,39 @@ pub fn build_soap_response( action: &str, values: Vec<(String, String)>, ) -> Result { - // Construire l'élément de réponse - // Format: let response_name = format!("u:{}Response", action); let mut response_elem = Element::new(&response_name); response_elem .attributes .insert("xmlns:u".to_string(), service_urn.to_string()); - // Ajouter les valeurs de retour for (key, value) in values { let mut child = Element::new(&key); child.children.push(XMLNode::Text(value)); response_elem.children.push(XMLNode::Element(child)); } - // Construire le Body - let mut body = Element::new("s:Body"); - body.children.push(XMLNode::Element(response_elem)); + build_soap_envelope_with_body(response_elem) +} - // Construire l'Envelope - let mut envelope = Element::new("s:Envelope"); - envelope.attributes.insert( - "xmlns:s".to_string(), - "http://schemas.xmlsoap.org/soap/envelope/".to_string(), - ); - envelope.attributes.insert( - "s:encodingStyle".to_string(), - "http://schemas.xmlsoap.org/soap/encoding/".to_string(), - ); - envelope.children.push(XMLNode::Element(body)); +pub fn build_soap_request( + service_urn: &str, + action: &str, + args: &[(&str, &str)], +) -> Result { + let request_name = format!("u:{}", action); + let mut request_elem = Element::new(&request_name); + request_elem + .attributes + .insert("xmlns:u".to_string(), service_urn.to_string()); - // Sérialiser en XML - let mut buf = Vec::new(); - let config = xmltree::EmitterConfig::new() - .write_document_declaration(true) - .perform_indent(true) - .indent_string(" "); - envelope.write_with_config(&mut buf, config)?; + for (name, value) in args { + let mut child = Element::new(*name); + child.children.push(XMLNode::Text((*value).to_string())); + request_elem.children.push(XMLNode::Element(child)); + } - Ok(String::from_utf8(buf).unwrap()) + build_soap_envelope_with_body(request_elem) } #[cfg(test)] diff --git a/pmoupnp/src/soap/mod.rs b/pmoupnp/src/soap/mod.rs index c2f7a63f..32d88e01 100644 --- a/pmoupnp/src/soap/mod.rs +++ b/pmoupnp/src/soap/mod.rs @@ -53,10 +53,10 @@ mod envelope; mod fault; mod parser; -pub use builder::build_soap_response; +pub use builder::{build_soap_response,build_soap_request}; pub use envelope::{SoapBody, SoapEnvelope, SoapHeader}; pub use fault::{SoapFault, build_soap_fault}; -pub use parser::{SoapAction, parse_soap_action}; +pub use parser::{SoapAction, parse_soap_action, parse_soap_envelope}; /// Codes d'erreur SOAP UPnP standards pub mod error_codes { diff --git a/pmoupnp/src/ssdp/client.rs b/pmoupnp/src/ssdp/client.rs index 39d57299..28c87a65 100644 --- a/pmoupnp/src/ssdp/client.rs +++ b/pmoupnp/src/ssdp/client.rs @@ -1,3 +1,22 @@ +/*! +The PMOMusic SSDP client is a *control point*. +It must **not** bind to UDP port 1900. + +Reason: + +* The SSDP *server* (UPnP device mode) must listen on 0.0.0.0:1900 for M-SEARCH discovery. +* The SSDP *client* only needs to send M-SEARCH and receive unicast HTTP/200 replies. +* If both client and server bind on 1900 (even with SO_REUSEPORT) the kernel load-balances + incoming datagrams between sockets. As a result, NOTIFY and HTTP/200 messages are lost + randomly by the client. + +Therefore: + +* SSDP server → bind(0.0.0.0:1900), join multicast, answer M-SEARCH. +* SSDP client → bind(0.0.0.0:0), use an ephemeral port, send M-SEARCH, receive replies. + +The client may still join the multicast group for debugging, but NOTIFY reception is optional. +*/ //! Client SSDP pour la découverte des devices UPnP use super::{MAX_AGE, SSDP_MULTICAST_ADDR, SSDP_PORT}; @@ -47,41 +66,35 @@ impl SsdpClient { let socket2 = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; socket2.set_reuse_address(true)?; - #[cfg(unix)] - { - use std::os::unix::io::AsRawFd; - let fd = socket2.as_raw_fd(); - let optval: libc::c_int = 1; - unsafe { - let result = libc::setsockopt( - fd, - libc::SOL_SOCKET, - libc::SO_REUSEPORT, - &optval as *const _ as *const libc::c_void, - std::mem::size_of_val(&optval) as libc::socklen_t, - ); - if result != 0 { - return Err(std::io::Error::last_os_error()); - } - } - debug!("✅ SsdpClient SO_REUSEPORT enabled (Unix)"); - } - #[cfg(windows)] { debug!("✅ SsdpClient SO_REUSEADDR enabled (Windows - SO_REUSEPORT not needed)"); } - let bind_addr: SocketAddr = format!("0.0.0.0:{}", SSDP_PORT).parse().unwrap(); + let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap(); socket2.bind(&bind_addr.into())?; let socket: UdpSocket = socket2.into(); - socket.join_multicast_v4( - &SSDP_MULTICAST_ADDR.parse().unwrap(), - &"0.0.0.0".parse().unwrap(), - )?; socket.set_read_timeout(Some(Duration::from_secs(1)))?; - socket.set_multicast_loop_v4(false)?; + socket.set_multicast_loop_v4(true)?; // utile en dev local + + for iface in get_if_addrs::get_if_addrs()? { + if let std::net::IpAddr::V4(ipv4) = iface.ip() { + if !ipv4.is_loopback() { + match socket.join_multicast_v4(&SSDP_MULTICAST_ADDR.parse().unwrap(), &ipv4) { + Ok(()) => { + debug!("SSDP: joined {} on {}", SSDP_MULTICAST_ADDR, ipv4); + } + Err(e) => { + warn!( + "SSDP: failed to join {} on {}: {}", + SSDP_MULTICAST_ADDR, ipv4, e + ); + } + } + } + } + } info!("✅ SSDP client ready on {}", addr); @@ -158,12 +171,16 @@ impl SsdpClient { fn parse_message(data: &str, from: SocketAddr) -> Option { let mut lines = data.lines(); let first_line = lines.next()?.trim(); + let upper = first_line.to_ascii_uppercase(); let headers = parse_headers(lines); - if first_line.to_ascii_uppercase().starts_with("NOTIFY") { + if upper.starts_with("NOTIFY ") { handle_notify(&headers, from) - } else if first_line.to_ascii_uppercase().starts_with("HTTP/1.1 200") { + } else if upper.starts_with("HTTP/") && upper.contains(" 200 ") { handle_search_response(&headers, from) + } else if upper.starts_with("M-SEARCH ") { + // Another control point querying us; we are not a device, so we ignore. + None } else { None }