implémentation concrète de DeviceDescriptionProvider
This commit is contained in:
53
Cargo.lock
generated
53
Cargo.lock
generated
@@ -3162,7 +3162,13 @@ dependencies = [
|
|||||||
name = "pmocontrol"
|
name = "pmocontrol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"pmoupnp",
|
"pmoupnp",
|
||||||
|
"quick-xml 0.38.4",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3427,6 +3433,7 @@ dependencies = [
|
|||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
"bevy_reflect_derive",
|
"bevy_reflect_derive",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"get_if_addrs",
|
||||||
"hex",
|
"hex",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -3999,7 +4006,9 @@ version = "0.23.35"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
|
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -5117,6 +5126,35 @@ version = "0.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
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]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.7"
|
version = "2.5.7"
|
||||||
@@ -5139,6 +5177,12 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -5377,6 +5421,15 @@ dependencies = [
|
|||||||
"libwebp-sys",
|
"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]]
|
[[package]]
|
||||||
name = "weezl"
|
name = "weezl"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
|
|||||||
@@ -5,3 +5,9 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pmoupnp = { path = "../pmoupnp" }
|
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"
|
||||||
|
|||||||
52
pmocontrol/examples/discover.rs
Normal file
52
pmocontrol/examples/discover.rs
Normal 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!("=====================");
|
||||||
|
}
|
||||||
|
}
|
||||||
59
pmocontrol/examples/dump_ssdp.rs
Normal file
59
pmocontrol/examples/dump_ssdp.rs
Normal 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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
79
pmocontrol/src/control_point.rs
Normal file
79
pmocontrol/src/control_point.rs
Normal 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(®istry);
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,14 @@ where
|
|||||||
max_age: u32,
|
max_age: u32,
|
||||||
updates: &mut Vec<DeviceUpdate>,
|
updates: &mut Vec<DeviceUpdate>,
|
||||||
) {
|
) {
|
||||||
|
tracing::debug!(
|
||||||
|
"SSDP update: udn={} type={} location={} max_age={}",
|
||||||
|
udn,
|
||||||
|
device_type,
|
||||||
|
location,
|
||||||
|
max_age
|
||||||
|
);
|
||||||
|
|
||||||
let endpoint = self
|
let endpoint = self
|
||||||
.endpoints
|
.endpoints
|
||||||
.entry(udn.clone())
|
.entry(udn.clone())
|
||||||
@@ -175,18 +183,30 @@ where
|
|||||||
});
|
});
|
||||||
|
|
||||||
endpoint.touch(location, server_header, max_age);
|
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) {
|
if !endpoint.seen_as_renderer {
|
||||||
endpoint.seen_as_renderer = true;
|
|
||||||
if let Some(info) = self.provider.build_renderer_info(endpoint) {
|
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));
|
updates.push(DeviceUpdate::RendererOnline(info));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_server_type(&device_type) {
|
if !endpoint.seen_as_server {
|
||||||
endpoint.seen_as_server = true;
|
|
||||||
if let Some(info) = self.provider.build_server_info(endpoint) {
|
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));
|
updates.push(DeviceUpdate::ServerOnline(info));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,13 +226,3 @@ fn extract_udn_from_usn(usn: &str) -> Option<String> {
|
|||||||
None
|
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:")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
|
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 registry;
|
pub mod registry;
|
||||||
|
|
||||||
|
pub use control_point::ControlPoint;
|
||||||
|
|
||||||
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
||||||
pub use model::{
|
pub use model::{
|
||||||
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
|
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
|
||||||
RendererInfo, RendererProtocol,
|
RendererInfo, RendererProtocol,
|
||||||
};
|
};
|
||||||
|
pub use provider::HttpXmlDescriptionProvider;
|
||||||
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
||||||
|
|
||||||
|
pub use soap_client::invoke_upnp_action;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
366
pmocontrol/src/provider.rs
Normal file
366
pmocontrol/src/provider.rs
Normal 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) = ¤t_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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
pmocontrol/src/soap_client.rs
Normal file
83
pmocontrol/src/soap_client.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ axum = "0.8.4"
|
|||||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync"] }
|
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
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"] }
|
chrono = { version = "0.4.42", features = ["serde"] }
|
||||||
once_cell = "1.20"
|
once_cell = "1.20"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
@@ -35,6 +35,7 @@ bevy_reflect_derive = "0.17.1"
|
|||||||
reqwest = "0.12.23"
|
reqwest = "0.12.23"
|
||||||
utoipa = { version = "5.3", features = ["axum_extras"] }
|
utoipa = { version = "5.3", features = ["axum_extras"] }
|
||||||
socket2 = "0.5"
|
socket2 = "0.5"
|
||||||
|
get_if_addrs = "0.5"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["server"]
|
default = ["server"]
|
||||||
|
|||||||
@@ -2,6 +2,34 @@
|
|||||||
|
|
||||||
use xmltree::{Element, XMLNode};
|
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
|
/// Construit une réponse SOAP UPnP
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
@@ -18,46 +46,39 @@ pub fn build_soap_response(
|
|||||||
action: &str,
|
action: &str,
|
||||||
values: Vec<(String, String)>,
|
values: Vec<(String, String)>,
|
||||||
) -> Result<String, xmltree::Error> {
|
) -> 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 response_name = format!("u:{}Response", action);
|
||||||
let mut response_elem = Element::new(&response_name);
|
let mut response_elem = Element::new(&response_name);
|
||||||
response_elem
|
response_elem
|
||||||
.attributes
|
.attributes
|
||||||
.insert("xmlns:u".to_string(), service_urn.to_string());
|
.insert("xmlns:u".to_string(), service_urn.to_string());
|
||||||
|
|
||||||
// Ajouter les valeurs de retour
|
|
||||||
for (key, value) in values {
|
for (key, value) in values {
|
||||||
let mut child = Element::new(&key);
|
let mut child = Element::new(&key);
|
||||||
child.children.push(XMLNode::Text(value));
|
child.children.push(XMLNode::Text(value));
|
||||||
response_elem.children.push(XMLNode::Element(child));
|
response_elem.children.push(XMLNode::Element(child));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construire le Body
|
build_soap_envelope_with_body(response_elem)
|
||||||
let mut body = Element::new("s:Body");
|
}
|
||||||
body.children.push(XMLNode::Element(response_elem));
|
|
||||||
|
|
||||||
// Construire l'Envelope
|
pub fn build_soap_request(
|
||||||
let mut envelope = Element::new("s:Envelope");
|
service_urn: &str,
|
||||||
envelope.attributes.insert(
|
action: &str,
|
||||||
"xmlns:s".to_string(),
|
args: &[(&str, &str)],
|
||||||
"http://schemas.xmlsoap.org/soap/envelope/".to_string(),
|
) -> Result<String, xmltree::Error> {
|
||||||
);
|
let request_name = format!("u:{}", action);
|
||||||
envelope.attributes.insert(
|
let mut request_elem = Element::new(&request_name);
|
||||||
"s:encodingStyle".to_string(),
|
request_elem
|
||||||
"http://schemas.xmlsoap.org/soap/encoding/".to_string(),
|
.attributes
|
||||||
);
|
.insert("xmlns:u".to_string(), service_urn.to_string());
|
||||||
envelope.children.push(XMLNode::Element(body));
|
|
||||||
|
|
||||||
// Sérialiser en XML
|
for (name, value) in args {
|
||||||
let mut buf = Vec::new();
|
let mut child = Element::new(*name);
|
||||||
let config = xmltree::EmitterConfig::new()
|
child.children.push(XMLNode::Text((*value).to_string()));
|
||||||
.write_document_declaration(true)
|
request_elem.children.push(XMLNode::Element(child));
|
||||||
.perform_indent(true)
|
}
|
||||||
.indent_string(" ");
|
|
||||||
envelope.write_with_config(&mut buf, config)?;
|
|
||||||
|
|
||||||
Ok(String::from_utf8(buf).unwrap())
|
build_soap_envelope_with_body(request_elem)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ mod envelope;
|
|||||||
mod fault;
|
mod fault;
|
||||||
mod parser;
|
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 envelope::{SoapBody, SoapEnvelope, SoapHeader};
|
||||||
pub use fault::{SoapFault, build_soap_fault};
|
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
|
/// Codes d'erreur SOAP UPnP standards
|
||||||
pub mod error_codes {
|
pub mod error_codes {
|
||||||
|
|||||||
@@ -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
|
//! Client SSDP pour la découverte des devices UPnP
|
||||||
|
|
||||||
use super::{MAX_AGE, SSDP_MULTICAST_ADDR, SSDP_PORT};
|
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))?;
|
let socket2 = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
|
||||||
socket2.set_reuse_address(true)?;
|
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)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
debug!("✅ SsdpClient SO_REUSEADDR enabled (Windows - SO_REUSEPORT not needed)");
|
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())?;
|
socket2.bind(&bind_addr.into())?;
|
||||||
|
|
||||||
let socket: UdpSocket = socket2.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_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);
|
info!("✅ SSDP client ready on {}", addr);
|
||||||
|
|
||||||
@@ -158,12 +171,16 @@ impl SsdpClient {
|
|||||||
fn parse_message(data: &str, from: SocketAddr) -> Option<SsdpEvent> {
|
fn parse_message(data: &str, from: SocketAddr) -> Option<SsdpEvent> {
|
||||||
let mut lines = data.lines();
|
let mut lines = data.lines();
|
||||||
let first_line = lines.next()?.trim();
|
let first_line = lines.next()?.trim();
|
||||||
|
let upper = first_line.to_ascii_uppercase();
|
||||||
let headers = parse_headers(lines);
|
let headers = parse_headers(lines);
|
||||||
|
|
||||||
if first_line.to_ascii_uppercase().starts_with("NOTIFY") {
|
if upper.starts_with("NOTIFY ") {
|
||||||
handle_notify(&headers, from)
|
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)
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user