diff --git a/Cargo.lock b/Cargo.lock index aad9c1b1..82818044 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3158,6 +3158,13 @@ dependencies = [ "uuid", ] +[[package]] +name = "pmocontrol" +version = "0.1.0" +dependencies = [ + "pmoupnp", +] + [[package]] name = "pmocovers" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 68467e41..8575c4c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,5 +19,5 @@ members = [ "pmosource", "pmoplaylist", "pmoflac", - "pmometadata", + "pmometadata", "pmocontrol", ] diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml new file mode 100644 index 00000000..db0beee0 --- /dev/null +++ b/pmocontrol/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "pmocontrol" +version = "0.1.0" +edition = "2024" + +[dependencies] +pmoupnp = { path = "../pmoupnp" } diff --git a/pmocontrol/src/discovery.rs b/pmocontrol/src/discovery.rs new file mode 100644 index 00000000..ce2f93f1 --- /dev/null +++ b/pmocontrol/src/discovery.rs @@ -0,0 +1,218 @@ +use std::collections::{HashMap, HashSet}; +use std::time::SystemTime; + +use pmoupnp::ssdp::SsdpEvent; + +use crate::model::{MediaServerInfo, RendererInfo}; +use crate::registry::DeviceUpdate; + +/// État connu pour un endpoint UPnP identifié par son UDN. +#[derive(Clone, Debug)] +pub struct DiscoveredEndpoint { + /// UDN normalisé (ex: "uuid:xxxx", en minuscules). + pub udn: String, + /// Dernière URL de description device (LOCATION SSDP). + pub location: String, + /// Dernier header SERVER vu sur cet endpoint. + pub server_header: String, + /// Dernier max-age indiqué (TTL SSDP). + pub max_age: u32, + /// Date de dernière vue (Now lors du dernier Alive ou SearchResponse). + pub last_seen: SystemTime, + /// Indique si on a vu ce endpoint comme MediaRenderer. + pub seen_as_renderer: bool, + /// Indique si on a vu ce endpoint comme MediaServer. + pub seen_as_server: bool, + /// ST/NT vus (pour debug/diagnostic si utile). + pub types_seen: HashSet, +} + +impl DiscoveredEndpoint { + pub fn new(udn: String, location: String, server_header: String, max_age: u32) -> Self { + Self { + udn, + location, + server_header, + max_age, + last_seen: SystemTime::now(), + seen_as_renderer: false, + seen_as_server: false, + types_seen: HashSet::new(), + } + } + + pub fn touch(&mut self, location: String, server_header: String, max_age: u32) { + self.location = location; + self.server_header = server_header; + self.max_age = max_age; + self.last_seen = SystemTime::now(); + } +} + +/// Fournit les descriptions haut niveau à partir d’un endpoint découvert. +/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location` +/// et parser la description pour remplir RendererInfo / MediaServerInfo. +pub trait DeviceDescriptionProvider: Send + Sync { + /// Construit un RendererInfo pour cet endpoint, ou None s’il + /// ne correspond pas à un renderer audio intéressant. + fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option; + + /// Construit un MediaServerInfo pour cet endpoint, ou None s’il + /// ne correspond pas à un media server (ou pas intéressant). + fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option; +} + +/// Gestionnaire des événements SSDP -> DeviceUpdate. +pub struct DiscoveryManager

+where + P: DeviceDescriptionProvider, +{ + endpoints: HashMap, + provider: P, +} + +impl

DiscoveryManager

+where + P: DeviceDescriptionProvider, +{ + pub fn new(provider: P) -> Self { + Self { + endpoints: HashMap::new(), + provider, + } + } + + pub fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec { + let mut updates = Vec::new(); + + match event { + SsdpEvent::Alive { + usn, + nt, + location, + server, + max_age, + .. + } => { + if let Some(udn) = extract_udn_from_usn(&usn) { + self.handle_alive(udn, nt, location, server, max_age, &mut updates); + } + } + SsdpEvent::SearchResponse { + usn, + st, + location, + server, + max_age, + .. + } => { + if let Some(udn) = extract_udn_from_usn(&usn) { + self.handle_search_response(udn, st, location, server, max_age, &mut updates); + } + } + SsdpEvent::ByeBye { usn, nt, .. } => { + if let Some(udn) = extract_udn_from_usn(&usn) { + self.handle_byebye(udn, nt, &mut updates); + } + } + } + + updates + } + + fn handle_alive( + &mut self, + udn: String, + nt: String, + location: String, + server_header: String, + max_age: u32, + updates: &mut Vec, + ) { + self.update_endpoint(udn, nt, location, server_header, max_age, updates); + } + + fn handle_search_response( + &mut self, + udn: String, + st: String, + location: String, + server_header: String, + max_age: u32, + updates: &mut Vec, + ) { + self.update_endpoint(udn, st, location, server_header, max_age, updates); + } + + fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec) { + if let Some(endpoint) = self.endpoints.get(&udn) { + if endpoint.seen_as_renderer { + updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone())); + } + if endpoint.seen_as_server { + updates.push(DeviceUpdate::ServerOfflineByUdn(udn)); + } + } + } + + fn update_endpoint( + &mut self, + udn: String, + device_type: String, + location: String, + server_header: String, + max_age: u32, + updates: &mut Vec, + ) { + let endpoint = self + .endpoints + .entry(udn.clone()) + .or_insert_with({ + let udn = udn.clone(); + let location = location.clone(); + let server_header = server_header.clone(); + move || DiscoveredEndpoint::new(udn, location, server_header, max_age) + }); + + endpoint.touch(location, server_header, max_age); + endpoint.types_seen.insert(device_type.clone()); + + if is_renderer_type(&device_type) { + endpoint.seen_as_renderer = true; + if let Some(info) = self.provider.build_renderer_info(endpoint) { + updates.push(DeviceUpdate::RendererOnline(info)); + } + } + + if is_server_type(&device_type) { + endpoint.seen_as_server = true; + if let Some(info) = self.provider.build_server_info(endpoint) { + updates.push(DeviceUpdate::ServerOnline(info)); + } + } + } +} + +fn extract_udn_from_usn(usn: &str) -> Option { + let lower = usn.trim().to_ascii_lowercase(); + if let Some(idx) = lower.find("uuid:") { + let sub = &lower[idx..]; + if let Some(end) = sub.find("::") { + Some(sub[..end].to_string()) + } else { + Some(sub.to_string()) + } + } else { + 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:") +} diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs new file mode 100644 index 00000000..51255f96 --- /dev/null +++ b/pmocontrol/src/lib.rs @@ -0,0 +1,10 @@ +pub mod discovery; +pub mod model; +pub mod registry; + +pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager}; +pub use model::{ + MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId, + RendererInfo, RendererProtocol, +}; +pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs new file mode 100644 index 00000000..f327cee4 --- /dev/null +++ b/pmocontrol/src/model.rs @@ -0,0 +1,66 @@ +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RendererId(pub String); + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct MediaServerId(pub String); + +#[derive(Clone, Debug)] +pub enum RendererProtocol { + UpnpAvOnly, + OpenHomeOnly, + Hybrid, +} + +#[derive(Clone, Debug, Default)] +pub struct RendererCapabilities { + pub has_avtransport: bool, + pub has_rendering_control: bool, + pub has_connection_manager: bool, + + pub has_oh_playlist: bool, + pub has_oh_volume: bool, + pub has_oh_info: bool, + pub has_oh_time: bool, + pub has_oh_radio: bool, +} + +#[derive(Clone, Debug)] +pub struct RendererInfo { + pub id: RendererId, + pub udn: String, + pub friendly_name: String, + pub model_name: String, + pub manufacturer: String, + + pub protocol: RendererProtocol, + pub capabilities: RendererCapabilities, + + pub location: String, + pub server_header: String, + pub online: bool, + pub last_seen: std::time::SystemTime, + pub max_age: u32, +} + +#[derive(Clone, Debug, Default)] +pub struct MediaServerCapabilities { + pub has_content_directory: bool, + pub has_connection_manager: bool, +} + +#[derive(Clone, Debug)] +pub struct MediaServerInfo { + pub id: MediaServerId, + pub udn: String, + pub friendly_name: String, + pub model_name: String, + pub manufacturer: String, + + pub capabilities: MediaServerCapabilities, + + pub location: String, + pub server_header: String, + pub online: bool, + pub last_seen: std::time::SystemTime, + pub max_age: u32, +} diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs new file mode 100644 index 00000000..8d3d47e4 --- /dev/null +++ b/pmocontrol/src/registry.rs @@ -0,0 +1,117 @@ +use std::collections::HashMap; +use std::time::SystemTime; + +use crate::model::{MediaServerId, MediaServerInfo, RendererId, RendererInfo}; + +#[derive(Clone, Debug)] +enum DeviceKey { + Renderer(RendererId), + Server(MediaServerId), +} + +#[derive(Debug, Default)] +pub struct DeviceRegistry { + renderers: HashMap, + servers: HashMap, + udn_index: HashMap, +} + +pub trait DeviceRegistryRead { + fn list_renderers(&self) -> Vec; + fn list_servers(&self) -> Vec; + + fn get_renderer(&self, id: &RendererId) -> Option; + fn get_server(&self, id: &MediaServerId) -> Option; +} + +impl DeviceRegistryRead for DeviceRegistry { + fn list_renderers(&self) -> Vec { + self.renderers.values().cloned().collect() + } + + fn list_servers(&self) -> Vec { + self.servers.values().cloned().collect() + } + + fn get_renderer(&self, id: &RendererId) -> Option { + self.renderers.get(id).cloned() + } + + fn get_server(&self, id: &MediaServerId) -> Option { + self.servers.get(id).cloned() + } +} + +#[derive(Debug)] +pub enum DeviceUpdate { + RendererOnline(RendererInfo), + RendererOfflineById(RendererId), + RendererOfflineByUdn(String), + + ServerOnline(MediaServerInfo), + ServerOfflineById(MediaServerId), + ServerOfflineByUdn(String), +} + +impl DeviceRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn apply_update(&mut self, update: DeviceUpdate) { + match update { + DeviceUpdate::RendererOnline(info) => { + let udn = info.udn.to_ascii_lowercase(); + let id = info.id.clone(); + let mut info = info; + + info.online = true; + info.last_seen = SystemTime::now(); + + self.renderers.insert(id.clone(), info); + self.udn_index.insert(udn, DeviceKey::Renderer(id)); + } + DeviceUpdate::RendererOfflineById(id) => { + if let Some(info) = self.renderers.get_mut(&id) { + info.online = false; + info.last_seen = SystemTime::now(); + } + } + DeviceUpdate::RendererOfflineByUdn(udn) => { + let lookup = udn.to_ascii_lowercase(); + if let Some(DeviceKey::Renderer(id)) = self.udn_index.get(&lookup) { + if let Some(info) = self.renderers.get_mut(id) { + info.online = false; + info.last_seen = SystemTime::now(); + } + } + } + DeviceUpdate::ServerOnline(info) => { + let udn = info.udn.to_ascii_lowercase(); + let id = info.id.clone(); + let mut info = info; + + info.online = true; + info.last_seen = SystemTime::now(); + + self.servers.insert(id.clone(), info); + self.udn_index.insert(udn, DeviceKey::Server(id)); + } + DeviceUpdate::ServerOfflineById(id) => { + if let Some(info) = self.servers.get_mut(&id) { + info.online = false; + info.last_seen = SystemTime::now(); + } + } + DeviceUpdate::ServerOfflineByUdn(udn) => { + let lookup = udn.to_ascii_lowercase(); + if let Some(DeviceKey::Server(id)) = self.udn_index.get(&lookup) { + if let Some(info) = self.servers.get_mut(id) { + info.online = false; + info.last_seen = SystemTime::now(); + } + } + } + } + } +} diff --git a/pmoupnp/src/ssdp/client.rs b/pmoupnp/src/ssdp/client.rs new file mode 100644 index 00000000..39d57299 --- /dev/null +++ b/pmoupnp/src/ssdp/client.rs @@ -0,0 +1,253 @@ +//! Client SSDP pour la découverte des devices UPnP + +use super::{MAX_AGE, SSDP_MULTICAST_ADDR, SSDP_PORT}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::collections::HashMap; +use std::net::{SocketAddr, UdpSocket}; +use std::sync::Arc; +use std::time::Duration; +use tracing::{debug, info, warn}; + +/// Événements SSDP intéressants pour un control point +#[derive(Debug, Clone)] +pub enum SsdpEvent { + Alive { + usn: String, + nt: String, + location: String, + server: String, + max_age: u32, + from: SocketAddr, + }, + ByeBye { + usn: String, + nt: String, + from: SocketAddr, + }, + SearchResponse { + usn: String, + st: String, + location: String, + server: String, + max_age: u32, + from: SocketAddr, + }, +} + +/// Client SSDP pour envoyer des M-SEARCH et écouter les annonces +pub struct SsdpClient { + socket: Arc, +} + +impl SsdpClient { + /// Crée un nouveau client SSDP + pub fn new() -> std::io::Result { + let addr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT); + + 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(); + 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)?; + + info!("✅ SSDP client ready on {}", addr); + + Ok(Self { + socket: Arc::new(socket), + }) + } + + /// Envoie un M-SEARCH pour un type donné + pub fn send_msearch(&self, st: &str, mx: u32) -> std::io::Result<()> { + let mx = mx.max(1); // MX doit être >= 1 + let msg = format!( + "M-SEARCH * HTTP/1.1\r\n\ + HOST: {}:{}\r\n\ + MAN: \"ssdp:discover\"\r\n\ + MX: {}\r\n\ + ST: {}\r\n\ + USER-AGENT: PMOMusic SSDP Client\r\n\ + \r\n", + SSDP_MULTICAST_ADDR, SSDP_PORT, mx, st + ); + + let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT) + .parse() + .unwrap(); + + match self.socket.send_to(msg.as_bytes(), addr) { + Ok(_) => { + info!("📤 M-SEARCH sent (ST={}, MX={})", st, mx); + debug!( + "📨 M-SEARCH payload\n

\n\n```\n{}\n```\n
\n", + msg + ); + Ok(()) + } + Err(e) => { + warn!("❌ Failed to send M-SEARCH: {}", e); + Err(e) + } + } + } + + /// Boucle de réception bloquante pour traiter les événements SSDP + pub fn run_event_loop(&self, mut on_event: F) -> ! + where + F: FnMut(SsdpEvent) + Send + 'static, + { + let socket = Arc::clone(&self.socket); + let mut buf = [0u8; 8192]; + loop { + match socket.recv_from(&mut buf) { + Ok((n, from)) => { + let data = String::from_utf8_lossy(&buf[..n]); + if let Some(event) = parse_message(&data, from) { + debug!( + "📥 SSDP datagram from {}\n
\n\n```\n{}\n```\n
\n", + from, data + ); + on_event(event); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // Timeout, recommencer + continue; + } + Err(e) => { + warn!("❌ SSDP client read error: {}", e); + } + } + } + } +} + +fn parse_message(data: &str, from: SocketAddr) -> Option { + let mut lines = data.lines(); + let first_line = lines.next()?.trim(); + let headers = parse_headers(lines); + + if first_line.to_ascii_uppercase().starts_with("NOTIFY") { + handle_notify(&headers, from) + } else if first_line.to_ascii_uppercase().starts_with("HTTP/1.1 200") { + handle_search_response(&headers, from) + } else { + None + } +} + +fn handle_notify(headers: &HashMap, from: SocketAddr) -> Option { + let nts = headers.get("NTS")?.to_ascii_lowercase(); + let nt = headers.get("NT")?.to_string(); + let usn = headers.get("USN")?.to_string(); + + if nts == "ssdp:alive" { + let location = headers.get("LOCATION")?.to_string(); + let server = headers.get("SERVER")?.to_string(); + let max_age = parse_max_age(headers.get("CACHE-CONTROL")); + Some(SsdpEvent::Alive { + usn, + nt, + location, + server, + max_age, + from, + }) + } else if nts == "ssdp:byebye" { + Some(SsdpEvent::ByeBye { usn, nt, from }) + } else { + None + } +} + +fn handle_search_response( + headers: &HashMap, + from: SocketAddr, +) -> Option { + let st = headers.get("ST")?.to_string(); + let usn = headers.get("USN")?.to_string(); + let location = headers.get("LOCATION")?.to_string(); + let server = headers.get("SERVER")?.to_string(); + let max_age = parse_max_age(headers.get("CACHE-CONTROL")); + + Some(SsdpEvent::SearchResponse { + usn, + st, + location, + server, + max_age, + from, + }) +} + +fn parse_headers<'a, I>(lines: I) -> HashMap +where + I: Iterator, +{ + let mut headers = HashMap::new(); + for line in lines { + let line = line.trim(); + if line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + headers.insert(name.trim().to_ascii_uppercase(), value.trim().to_string()); + } + } + headers +} + +fn parse_max_age(value: Option<&String>) -> u32 { + if let Some(v) = value { + for part in v.split(',') { + let part = part.trim(); + if let Some(rest) = part.strip_prefix("max-age=") { + if let Ok(age) = rest.trim().parse::() { + return age; + } + } else { + let lower = part.to_ascii_lowercase(); + if let Some(idx) = lower.find("max-age=") { + let raw = &part[idx + 8..]; + if let Ok(age) = raw.trim().parse::() { + return age; + } + } + } + } + } + MAX_AGE +} diff --git a/pmoupnp/src/ssdp/mod.rs b/pmoupnp/src/ssdp/mod.rs index 17e5cb97..54bcf59a 100644 --- a/pmoupnp/src/ssdp/mod.rs +++ b/pmoupnp/src/ssdp/mod.rs @@ -22,9 +22,11 @@ //! - **Max-Age**: 1800 secondes (30 minutes) //! - **Announcement Period**: 900 secondes (15 minutes, Max-Age/2) +mod client; mod device; mod server; +pub use client::{SsdpClient, SsdpEvent}; pub use device::SsdpDevice; pub use server::SsdpServer; diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index f6431ddc..2813464d 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -290,7 +290,7 @@ impl UpnpServerExt for Server { limit: usize, ) -> Result, anyhow::Error> { // Délègue à l'implémentation pmocovers (qui enregistre WebP + JPEG + API) - use pmocovers::CoverCacheExt; + let cache = pmocovers::CoverCacheExt::init_cover_cache(self, cache_dir, limit).await?; Ok(cache) }