diff --git a/pmocontrol/src/connection_manager_client.rs b/pmocontrol/src/connection_manager_client.rs new file mode 100644 index 00000000..47662371 --- /dev/null +++ b/pmocontrol/src/connection_manager_client.rs @@ -0,0 +1,294 @@ +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 ConnectionManagerClient { + pub control_url: String, + pub service_type: String, +} + +#[derive(Debug, Clone)] +pub struct ProtocolInfo { + /// Liste brute des protocolInfo "source" (séparés par virgule dans UPnP) + pub source: Vec, + /// Liste brute des protocolInfo "sink" + pub sink: Vec, +} + +#[derive(Debug, Clone)] +pub struct ConnectionInfo { + pub rcs_id: i32, + pub av_transport_id: i32, + pub protocol_info: String, + pub peer_connection_manager: String, + pub peer_connection_id: i32, + pub direction: String, + pub status: String, +} + +impl ConnectionManagerClient { + pub fn new(control_url: String, service_type: String) -> Self { + Self { + control_url, + service_type, + } + } + + /// GetProtocolInfo + pub fn get_protocol_info(&self) -> Result { + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetProtocolInfo", + &[], + )?; + + ensure_success("GetProtocolInfo", &call_result)?; + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetProtocolInfo response"))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(anyhow!( + "GetProtocolInfo returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + + let response = find_child_with_suffix(&envelope.body.content, "GetProtocolInfoResponse") + .ok_or_else(|| anyhow!("Missing GetProtocolInfoResponse element in SOAP body"))?; + + let source_text = extract_child_text_allow_empty(response, "Source")?; + let sink_text = extract_child_text_allow_empty(response, "Sink")?; + + Ok(ProtocolInfo { + source: split_list(&source_text), + sink: split_list(&sink_text), + }) + } + + /// GetCurrentConnectionIDs + pub fn get_current_connection_ids(&self) -> Result> { + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetCurrentConnectionIDs", + &[], + )?; + + ensure_success("GetCurrentConnectionIDs", &call_result)?; + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetCurrentConnectionIDs response"))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(anyhow!( + "GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + + let response = + find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse") + .ok_or_else(|| { + anyhow!("Missing GetCurrentConnectionIDsResponse element in SOAP body") + })?; + + let ids_text = extract_child_text_allow_empty(response, "ConnectionIDs")?; + let trimmed = ids_text.trim(); + + if trimmed.is_empty() || trimmed == "0" { + return Ok(Vec::new()); + } + + let mut ids = Vec::new(); + for part in trimmed.split(',') { + let value = part.trim(); + if value.is_empty() { + continue; + } + let parsed = value + .parse::() + .map_err(|_| anyhow!("Invalid ConnectionID value: {}", value))?; + ids.push(parsed); + } + + Ok(ids) + } + + /// GetCurrentConnectionInfo + pub fn get_current_connection_info(&self, connection_id: i32) -> Result { + let connection_id_str = connection_id.to_string(); + let args = [("ConnectionID", connection_id_str.as_str())]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetCurrentConnectionInfo", + &args, + )?; + + ensure_success("GetCurrentConnectionInfo", &call_result)?; + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetCurrentConnectionInfo response"))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(anyhow!( + "GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + + let response = + find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse") + .ok_or_else(|| { + anyhow!("Missing GetCurrentConnectionInfoResponse element in SOAP body") + })?; + + let rcs_id = extract_child_text(response, "RcsID")? + .parse::() + .map_err(|_| anyhow!("Invalid RcsID value in response"))?; + + let av_transport_id = extract_child_text(response, "AVTransportID")? + .parse::() + .map_err(|_| anyhow!("Invalid AVTransportID value in response"))?; + + let protocol_info = extract_child_text_allow_empty(response, "ProtocolInfo")?; + let peer_connection_manager = + extract_child_text_allow_empty(response, "PeerConnectionManager")?; + + let peer_connection_id = extract_child_text(response, "PeerConnectionID")? + .parse::() + .map_err(|_| anyhow!("Invalid PeerConnectionID value in response"))?; + + let direction = extract_child_text(response, "Direction")?; + let status = extract_child_text(response, "Status")?; + + Ok(ConnectionInfo { + rcs_id, + av_transport_id, + protocol_info, + peer_connection_manager, + peer_connection_id, + direction, + status, + }) + } +} + +fn split_list(value: &str) -> Vec { + value + .split(',') + .filter_map(|part| { + let trimmed = part.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + .collect() +} + +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 + )) +} + +#[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 text = extract_child_text_allow_empty(parent, suffix)?; + if text.is_empty() { + return Err(anyhow!("{suffix} element missing text in response")); + } + Ok(text) +} + +fn extract_child_text_allow_empty(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()) + .unwrap_or_default(); + + Ok(text) +} diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index c401c2bd..a84eb5cb 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -1,4 +1,5 @@ pub mod avtransport_client; +pub mod connection_manager_client; pub mod control_point; pub mod discovery; pub mod model; @@ -9,6 +10,7 @@ pub mod registry; pub mod soap_client; pub use avtransport_client::{AvTransportClient, TransportInfo}; +pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; pub use control_point::ControlPoint; pub use rendering_control_client::RenderingControlClient; pub use renderer::Renderer; diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index e6e11a2c..4442cac2 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -45,6 +45,8 @@ pub struct RendererInfo { pub avtransport_control_url: Option, pub rendering_control_service_type: Option, pub rendering_control_control_url: Option, + pub connection_manager_service_type: Option, + pub connection_manager_control_url: Option, } #[derive(Clone, Debug, Default)] diff --git a/pmocontrol/src/provider.rs b/pmocontrol/src/provider.rs index 491b6e29..fb1d49e8 100644 --- a/pmocontrol/src/provider.rs +++ b/pmocontrol/src/provider.rs @@ -46,6 +46,10 @@ struct ParsedDeviceDescription { // RenderingControl endpoint (if present in serviceList) rendering_control_service_type: Option, rendering_control_control_url: Option, + + // ConnectionManager endpoint (if present in serviceList) + connection_manager_service_type: Option, + connection_manager_control_url: Option, } impl ParsedDeviceDescription { @@ -178,6 +182,20 @@ impl HttpXmlDescriptionProvider { ); } } + + if lower + .contains("urn:schemas-upnp-org:service:connectionmanager:") + { + if parsed.connection_manager_service_type.is_none() { + parsed.connection_manager_service_type = Some(st.clone()); + parsed.connection_manager_control_url = + Some(ctrl.clone()); + debug!( + "Found ConnectionManager service for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } } in_service = false; @@ -282,6 +300,11 @@ impl HttpXmlDescriptionProvider { .rendering_control_control_url .as_ref() .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), + connection_manager_service_type: parsed.connection_manager_service_type.clone(), + connection_manager_control_url: parsed + .connection_manager_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), }) } diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs index 866012d0..3f49d2c9 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::connection_manager_client::ConnectionManagerClient; use crate::rendering_control_client::RenderingControlClient; use crate::model::{MediaServerId, MediaServerInfo, RendererId, RendererInfo}; @@ -175,4 +176,20 @@ impl DeviceRegistry { service_type.clone(), )) } + + /// Construct a ConnectionManagerClient for a given renderer id, if possible. + pub fn connection_manager_client_for_renderer( + &self, + id: &RendererId, + ) -> Option { + let info = self.renderers.get(id)?; + + let service_type = info.connection_manager_service_type.as_ref()?; + let control_url = info.connection_manager_control_url.as_ref()?; + + Some(ConnectionManagerClient::new( + control_url.clone(), + service_type.clone(), + )) + } } diff --git a/pmocontrol/src/renderer.rs b/pmocontrol/src/renderer.rs index 9889c1a9..bf3114c4 100644 --- a/pmocontrol/src/renderer.rs +++ b/pmocontrol/src/renderer.rs @@ -1,5 +1,8 @@ use anyhow::{anyhow, Result}; +use crate::connection_manager_client::{ + ConnectionInfo, ConnectionManagerClient, ProtocolInfo, +}; use crate::rendering_control_client::RenderingControlClient; use crate::{AvTransportClient, DeviceRegistry, RendererId, RendererInfo}; @@ -8,6 +11,7 @@ pub struct Renderer { pub info: RendererInfo, avtransport: Option, rendering_control: Option, + connection_manager: Option, } impl Renderer { @@ -27,6 +31,10 @@ impl Renderer { self.rendering_control.is_some() } + pub fn has_connection_manager(&self) -> bool { + self.connection_manager.is_some() + } + pub fn avtransport(&self) -> Result<&AvTransportClient> { self.avtransport .as_ref() @@ -39,6 +47,12 @@ impl Renderer { .ok_or_else(|| anyhow!("Renderer has no RenderingControl service")) } + pub fn connection_manager(&self) -> Result<&ConnectionManagerClient> { + self.connection_manager + .as_ref() + .ok_or_else(|| anyhow!("Renderer has no ConnectionManager service")) + } + pub fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { let avt = self.avtransport()?; avt.set_av_transport_uri(uri, meta)?; @@ -80,13 +94,30 @@ impl Renderer { rc.set_mute(0, "Master", mute) } + pub fn protocol_info(&self) -> Result { + let cm = self.connection_manager()?; + cm.get_protocol_info() + } + + pub fn connection_ids(&self) -> Result> { + let cm = self.connection_manager()?; + cm.get_current_connection_ids() + } + + pub fn connection_info(&self, connection_id: i32) -> Result { + let cm = self.connection_manager()?; + cm.get_current_connection_info(connection_id) + } + 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); + let connection_manager = registry.connection_manager_client_for_renderer(&info.id); Self { info, avtransport, rendering_control, + connection_manager, } } } @@ -121,6 +152,8 @@ mod tests { .then(|| "http://127.0.0.1/avtransport".into()), rendering_control_service_type: None, rendering_control_control_url: None, + connection_manager_service_type: None, + connection_manager_control_url: None, } }