avtransport client
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3169,6 +3169,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
"xmltree 0.11.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ ureq = "3.1.4"
|
|||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
xmltree = "0.11.0"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::sync::Arc;
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use pmocontrol::{ControlPoint, DeviceRegistryRead, MediaServerInfo, RendererInfo};
|
|
||||||
use pmocontrol::RendererProtocol;
|
use pmocontrol::RendererProtocol;
|
||||||
|
use pmocontrol::{ControlPoint, DeviceRegistryRead, MediaServerInfo, RendererInfo};
|
||||||
|
|
||||||
fn main() -> std::io::Result<()> {
|
fn main() -> std::io::Result<()> {
|
||||||
// Un tout petit logging optionnel
|
// Un tout petit logging optionnel
|
||||||
|
|||||||
101
pmocontrol/examples/test_avtransport.rs
Normal file
101
pmocontrol/examples/test_avtransport.rs
Normal 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(())
|
||||||
|
}
|
||||||
128
pmocontrol/src/avtransport_client.rs
Normal file
128
pmocontrol/src/avtransport_client.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -172,15 +172,12 @@ where
|
|||||||
max_age
|
max_age
|
||||||
);
|
);
|
||||||
|
|
||||||
let endpoint = self
|
let endpoint = self.endpoints.entry(udn.clone()).or_insert_with({
|
||||||
.endpoints
|
let udn = udn.clone();
|
||||||
.entry(udn.clone())
|
let location = location.clone();
|
||||||
.or_insert_with({
|
let server_header = server_header.clone();
|
||||||
let udn = udn.clone();
|
move || DiscoveredEndpoint::new(udn, location, server_header, max_age)
|
||||||
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.touch(location, server_header, max_age);
|
||||||
endpoint.types_seen.insert(device_type);
|
endpoint.types_seen.insert(device_type);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
pub mod avtransport_client;
|
||||||
pub mod control_point;
|
pub mod control_point;
|
||||||
pub mod soap_client;
|
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
pub mod soap_client;
|
||||||
|
|
||||||
|
pub use avtransport_client::{AvTransportClient, TransportInfo};
|
||||||
pub use control_point::ControlPoint;
|
pub use control_point::ControlPoint;
|
||||||
|
|
||||||
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
||||||
@@ -16,7 +18,3 @@ pub use provider::HttpXmlDescriptionProvider;
|
|||||||
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
||||||
|
|
||||||
pub use soap_client::invoke_upnp_action;
|
pub use soap_client::invoke_upnp_action;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ pub struct RendererInfo {
|
|||||||
pub online: bool,
|
pub online: bool,
|
||||||
pub last_seen: std::time::SystemTime,
|
pub last_seen: std::time::SystemTime,
|
||||||
pub max_age: u32,
|
pub max_age: u32,
|
||||||
|
|
||||||
|
pub avtransport_service_type: Option<String>,
|
||||||
|
pub avtransport_control_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use quick_xml::{Error as XmlError, Reader, events::Event};
|
use quick_xml::{Error as XmlError, Reader, events::Event};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
use crate::discovery::DiscoveredEndpoint;
|
use crate::avtransport_client::AvTransportClient;
|
||||||
|
use crate::discovery::{DeviceDescriptionProvider, DiscoveredEndpoint};
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
|
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
|
||||||
RendererInfo, RendererProtocol,
|
RendererInfo, RendererProtocol,
|
||||||
@@ -28,6 +29,7 @@ pub enum DescriptionError {
|
|||||||
MissingField(&'static str),
|
MissingField(&'static str),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parsed device description, plus (optionally) AVTransport endpoint.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct ParsedDeviceDescription {
|
struct ParsedDeviceDescription {
|
||||||
udn: Option<String>,
|
udn: Option<String>,
|
||||||
@@ -36,6 +38,10 @@ struct ParsedDeviceDescription {
|
|||||||
manufacturer: Option<String>,
|
manufacturer: Option<String>,
|
||||||
model_name: Option<String>,
|
model_name: Option<String>,
|
||||||
service_types: Vec<String>,
|
service_types: Vec<String>,
|
||||||
|
|
||||||
|
// New: AVTransport endpoint (if present in serviceList)
|
||||||
|
avtransport_service_type: Option<String>,
|
||||||
|
avtransport_control_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ParsedDeviceDescription {
|
impl ParsedDeviceDescription {
|
||||||
@@ -53,6 +59,7 @@ impl ParsedDeviceDescription {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HTTP-based XML description provider (UPnP device description.xml)
|
||||||
pub struct HttpXmlDescriptionProvider {
|
pub struct HttpXmlDescriptionProvider {
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
}
|
}
|
||||||
@@ -62,6 +69,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
Self { timeout_secs }
|
Self { timeout_secs }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch and parse the device description.xml at endpoint.location.
|
||||||
fn fetch_and_parse(
|
fn fetch_and_parse(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &DiscoveredEndpoint,
|
endpoint: &DiscoveredEndpoint,
|
||||||
@@ -72,7 +80,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let config = Agent::config_builder()
|
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();
|
.build();
|
||||||
|
|
||||||
let agent: Agent = config.into();
|
let agent: Agent = config.into();
|
||||||
@@ -99,6 +107,10 @@ impl HttpXmlDescriptionProvider {
|
|||||||
let mut in_service = false;
|
let mut in_service = false;
|
||||||
let mut current_tag: Option<String> = None;
|
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 {
|
loop {
|
||||||
match reader.read_event_into(&mut buf)? {
|
match reader.read_event_into(&mut buf)? {
|
||||||
Event::Start(e) => {
|
Event::Start(e) => {
|
||||||
@@ -112,6 +124,8 @@ impl HttpXmlDescriptionProvider {
|
|||||||
if in_device {
|
if in_device {
|
||||||
in_service = true;
|
in_service = true;
|
||||||
current_tag = None;
|
current_tag = None;
|
||||||
|
current_service_type = None;
|
||||||
|
current_control_url = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -128,7 +142,31 @@ impl HttpXmlDescriptionProvider {
|
|||||||
in_device = false;
|
in_device = false;
|
||||||
}
|
}
|
||||||
"service" => {
|
"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)) =
|
||||||
|
(¤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 in_device {
|
||||||
if let Some(tag) = ¤t_tag {
|
if let Some(tag) = ¤t_tag {
|
||||||
// quick-xml ≥ 0.37 : unescape() → decode()
|
// quick-xml ≥ 0.37 : unescape() → decode()
|
||||||
let text = e
|
let text = e.decode().map_err(XmlError::Encoding)?.into_owned();
|
||||||
.decode() // Result<Cow<'_, str>, EncodingError>
|
|
||||||
.map_err(XmlError::Encoding)? // -> quick_xml::Error, donc DescriptionError::Xml via #[from]
|
|
||||||
.into_owned(); // String
|
|
||||||
|
|
||||||
match tag.as_str() {
|
match tag.as_str() {
|
||||||
"UDN" => {
|
"UDN" => {
|
||||||
@@ -160,7 +195,11 @@ impl HttpXmlDescriptionProvider {
|
|||||||
parsed.model_name = Some(text);
|
parsed.model_name = Some(text);
|
||||||
}
|
}
|
||||||
"serviceType" if in_service => {
|
"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,
|
online: true,
|
||||||
last_seen: now,
|
last_seen: now,
|
||||||
max_age: endpoint.max_age,
|
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,
|
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 {
|
fn detect_renderer_capabilities(service_types: &[String]) -> RendererCapabilities {
|
||||||
let mut caps = RendererCapabilities::default();
|
let mut caps = RendererCapabilities::default();
|
||||||
|
|
||||||
@@ -321,7 +396,32 @@ fn detect_server_capabilities(service_types: &[String]) -> MediaServerCapabiliti
|
|||||||
caps
|
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 {
|
impl DeviceDescriptionProvider for HttpXmlDescriptionProvider {
|
||||||
fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option<RendererInfo> {
|
fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option<RendererInfo> {
|
||||||
@@ -363,4 +463,4 @@ impl DeviceDescriptionProvider for HttpXmlDescriptionProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use crate::avtransport_client::AvTransportClient;
|
||||||
use crate::model::{MediaServerId, MediaServerInfo, RendererId, RendererInfo};
|
use crate::model::{MediaServerId, MediaServerInfo, RendererId, RendererInfo};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -16,6 +17,10 @@ pub struct DeviceRegistry {
|
|||||||
udn_index: HashMap<String, DeviceKey>,
|
udn_index: HashMap<String, DeviceKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
pub trait DeviceRegistryRead {
|
||||||
fn list_renderers(&self) -> Vec<RendererInfo>;
|
fn list_renderers(&self) -> Vec<RendererInfo>;
|
||||||
fn list_servers(&self) -> Vec<MediaServerInfo>;
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,37 +15,31 @@ pub struct SoapCallResult {
|
|||||||
/// Invoke a UPnP SOAP action on a control URL.
|
/// Invoke a UPnP SOAP action on a control URL.
|
||||||
///
|
///
|
||||||
/// - `control_url`: full HTTP URL of the service control endpoint
|
/// - `control_url`: full HTTP URL of the service control endpoint
|
||||||
/// - `service_type`: service URN, e.g. "urn:schemas-upnp-org:service:AVTransport:1"
|
/// - `service_type`: service URN
|
||||||
/// - `action`: action name, e.g. "GetTransportInfo"
|
/// - `action`: action name
|
||||||
/// - `args`: list of (name, value) pairs, e.g. &[("InstanceID", "0")]
|
/// - `args`: list of (name, value)
|
||||||
pub fn invoke_upnp_action(
|
pub fn invoke_upnp_action(
|
||||||
control_url: &str,
|
control_url: &str,
|
||||||
service_type: &str,
|
service_type: &str,
|
||||||
action: &str,
|
action: &str,
|
||||||
args: &[(&str, &str)],
|
args: &[(&str, &str)],
|
||||||
) -> Result<SoapCallResult> {
|
) -> 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)
|
let body_xml = build_soap_request(service_type, action, args)
|
||||||
.context("Failed to build SOAP request body")?;
|
.context("Failed to build SOAP request body")?;
|
||||||
|
|
||||||
// 2. Build an Agent config that does NOT treat 4xx/5xx as errors.
|
// 2. Build agent 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()
|
let config = Agent::config_builder()
|
||||||
.http_status_as_error(false)
|
.http_status_as_error(false)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let agent: Agent = config.into();
|
let agent: Agent = config.into();
|
||||||
|
|
||||||
// 3. Build SOAPAction header: "urn:service#Action"
|
// 3. SOAPAction header
|
||||||
let soap_action_header = format!(r#""{}#{}""#, service_type, action);
|
let soap_action_header = format!(r#""{}#{}""#, service_type, action);
|
||||||
|
|
||||||
// 4. Send HTTP POST request
|
// 4. HTTP POST
|
||||||
//
|
|
||||||
// - RequestBuilder::header(...) is the proper 3.x API.
|
|
||||||
// - RequestBuilder::send(...) accepts anything implementing AsSendBody,
|
|
||||||
// including `String`.
|
|
||||||
let mut response = agent
|
let mut response = agent
|
||||||
.post(control_url)
|
.post(control_url)
|
||||||
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
||||||
@@ -55,21 +49,16 @@ pub fn invoke_upnp_action(
|
|||||||
|
|
||||||
let status = response.status();
|
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:
|
// API réelle (ureq 3.1.4):
|
||||||
// response.body_mut().read_to_string(...)
|
// body_mut().read_to_string() -> Result<String, ureq::Error>
|
||||||
let raw_body = String::new();
|
let raw_body = response
|
||||||
response
|
|
||||||
.body_mut()
|
.body_mut()
|
||||||
.read_to_string()
|
.read_to_string()
|
||||||
.context("Failed to read SOAP response body")?;
|
.context("Failed to read SOAP response body")?;
|
||||||
|
|
||||||
// 6. Try to parse SOAP envelope.
|
// 6. Try to parse SOAP envelope; non-fatal on failure
|
||||||
//
|
|
||||||
// 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()) {
|
let envelope = match parse_soap_envelope(raw_body.as_bytes()) {
|
||||||
Ok(env) => Some(env),
|
Ok(env) => Some(env),
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
|
|||||||
Reference in New Issue
Block a user