push-pqqsxyupswry #21

Merged
eric merged 76 commits from push-pqqsxyupswry into main 2025-12-06 12:49:47 +01:00
11 changed files with 412 additions and 50 deletions
Showing only changes of commit bed0eca7f6 - Show all commits

1
Cargo.lock generated
View File

@@ -3169,6 +3169,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"ureq",
"xmltree 0.11.0",
]
[[package]]

View File

@@ -11,3 +11,4 @@ ureq = "3.1.4"
tracing = "0.1.41"
tracing-subscriber = "0.3"
anyhow = "1.0"
xmltree = "0.11.0"

View File

@@ -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

View File

@@ -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(())
}

View File

@@ -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<TransportInfo> {
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<TransportInfo> {
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<String> {
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");
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<String>,
pub avtransport_control_url: Option<String>,
}
#[derive(Clone, Debug, Default)]

View File

@@ -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<String>,
@@ -36,6 +38,10 @@ struct ParsedDeviceDescription {
manufacturer: Option<String>,
model_name: Option<String>,
service_types: Vec<String>,
// New: AVTransport endpoint (if present in serviceList)
avtransport_service_type: Option<String>,
avtransport_control_url: Option<String>,
}
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<String> = None;
// New: track current serviceType + controlURL while inside <service>...</service>
let mut current_service_type: Option<String> = None;
let mut current_control_url: Option<String> = 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 <service> block: if this is AVTransport,
// store its endpoint in parsed.*
if let (Some(st), Some(ctrl)) =
(&current_service_type, &current_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) = &current_tag {
// quick-xml ≥ 0.37 : unescape() → decode()
let text = e
.decode() // Result<Cow<'_, str>, 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<Option<AvTransportClient>, 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<RendererInfo> {
@@ -363,4 +463,4 @@ impl DeviceDescriptionProvider for HttpXmlDescriptionProvider {
}
}
}
}
}

View File

@@ -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<String, DeviceKey>,
}
/// Read-only view / trait for registry access.
///
/// Pour linstant, on ne rajoute pas AVTransport ici, on se contente
/// dajouter les helpers dans `impl DeviceRegistry`.
pub trait DeviceRegistryRead {
fn list_renderers(&self) -> Vec<RendererInfo>;
fn list_servers(&self) -> Vec<MediaServerInfo>;
@@ -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<RendererInfo> {
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<AvTransportClient> {
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<AvTransportClient> {
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))
}
}

View File

@@ -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<SoapCallResult> {
// 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<String, ureq::Error>
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,