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

BIN
.DS_Store vendored

Binary file not shown.

53
Cargo.lock generated
View File

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

View File

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

View File

@@ -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<RendererInfo> = reg.list_renderers();
let servers: Vec<MediaServerInfo> = 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!("=====================");
}
}

View File

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

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,
})
}

View File

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

View File

@@ -2,6 +2,34 @@
use xmltree::{Element, XMLNode};
fn build_soap_envelope_with_body(body_child: Element) -> Result<String, xmltree::Error> {
// 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<String, xmltree::Error> {
// Construire l'élément de réponse
// Format: <u:ActionResponse xmlns:u="service-urn">
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<String, xmltree::Error> {
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)]

View File

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

View File

@@ -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<SsdpEvent> {
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
}