implémentation concrète de DeviceDescriptionProvider

This commit is contained in:
2025-11-29 18:56:11 +01:00
parent 6cbf4791cd
commit 3ec692ea6f
14 changed files with 830 additions and 71 deletions

View File

@@ -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<RwLock<DeviceRegistry>>,
}
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<Self> {
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(&registry);
// 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<DeviceUpdate> = 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<RwLock<DeviceRegistry>> {
Arc::clone(&self.registry)
}
}

View File

@@ -164,6 +164,14 @@ where
max_age: u32,
updates: &mut Vec<DeviceUpdate>,
) {
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<String> {
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:")
}

View File

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

366
pmocontrol/src/provider.rs Normal file
View File

@@ -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<String>,
device_type: Option<String>,
friendly_name: Option<String>,
manufacturer: Option<String>,
model_name: Option<String>,
service_types: Vec<String>,
}
impl ParsedDeviceDescription {
fn require_fields(self) -> Result<Self, DescriptionError> {
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<ParsedDeviceDescription, DescriptionError> {
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<ureq::Body>
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<String> = 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) = &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
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<RendererInfo> {
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<MediaServerInfo> {
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<RendererInfo> {
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<MediaServerInfo> {
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
}
}
}
}

View File

@@ -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<SoapEnvelope>,
}
/// 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<SoapCallResult> {
// 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,
})
}