From 222152cdf7ca8711678856e65334fba946fc24c7 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Mon, 6 Oct 2025 12:05:56 +0200 Subject: [PATCH] Developement de la couche sspd --- pmoupnp/src/lib.rs | 2 + pmoupnp/src/soap/builder.rs | 101 ++++++++++++ pmoupnp/src/soap/envelope.rs | 45 ++++++ pmoupnp/src/soap/fault.rs | 173 ++++++++++++++++++++ pmoupnp/src/soap/mod.rs | 89 ++++++++++ pmoupnp/src/soap/parser.rs | 149 +++++++++++++++++ pmoupnp/src/ssdp/device.rs | 58 +++++++ pmoupnp/src/ssdp/mod.rs | 38 +++++ pmoupnp/src/ssdp/server.rs | 304 +++++++++++++++++++++++++++++++++++ 9 files changed, 959 insertions(+) create mode 100644 pmoupnp/src/soap/builder.rs create mode 100644 pmoupnp/src/soap/envelope.rs create mode 100644 pmoupnp/src/soap/fault.rs create mode 100644 pmoupnp/src/soap/mod.rs create mode 100644 pmoupnp/src/soap/parser.rs create mode 100644 pmoupnp/src/ssdp/device.rs create mode 100644 pmoupnp/src/ssdp/mod.rs create mode 100644 pmoupnp/src/ssdp/server.rs diff --git a/pmoupnp/src/lib.rs b/pmoupnp/src/lib.rs index 9930f3d0..a23d1cbc 100644 --- a/pmoupnp/src/lib.rs +++ b/pmoupnp/src/lib.rs @@ -6,6 +6,8 @@ pub mod devices; pub mod mediarenderer; pub mod server; pub mod services; +pub mod soap; +pub mod ssdp; pub mod state_variables; pub mod value_ranges; pub mod variable_types; diff --git a/pmoupnp/src/soap/builder.rs b/pmoupnp/src/soap/builder.rs new file mode 100644 index 00000000..7e9d4c7e --- /dev/null +++ b/pmoupnp/src/soap/builder.rs @@ -0,0 +1,101 @@ +//! Construction de réponses SOAP + +use std::collections::HashMap; +use xmltree::{Element, XMLNode}; + +/// Construit une réponse SOAP UPnP +/// +/// # Arguments +/// +/// * `service_urn` - URN du service (ex: "urn:schemas-upnp-org:service:AVTransport:1") +/// * `action` - Nom de l'action (ex: "GetPositionInfo") +/// * `values` - Map des valeurs de retour +/// +/// # Returns +/// +/// XML SOAP formaté en String +pub fn build_soap_response( + service_urn: &str, + action: &str, + values: HashMap, +) -> Result { + // Construire l'élément de réponse + // Format: + let response_name = format!("{}Response", action); + let mut response_elem = Element::new(&response_name); + response_elem.namespace = Some(service_urn.to_string()); + 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)); + + // 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)); + + // Sérialiser en XML + let mut buf = Vec::new(); + let config = xmltree::EmitterConfig::new() + .perform_indent(true) + .indent_string(" "); + envelope.write_with_config(&mut buf, config)?; + + Ok(String::from_utf8(buf).unwrap()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_response() { + let mut values = HashMap::new(); + values.insert("Track".to_string(), "5".to_string()); + values.insert("TrackDuration".to_string(), "00:03:45".to_string()); + + let xml = build_soap_response( + "urn:schemas-upnp-org:service:AVTransport:1", + "GetPositionInfo", + values, + ) + .unwrap(); + + assert!(xml.contains("GetPositionInfoResponse")); + assert!(xml.contains("5")); + assert!(xml.contains("00:03:45")); + assert!(xml.contains("xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"")); + } + + #[test] + fn test_build_empty_response() { + let values = HashMap::new(); + + let xml = build_soap_response( + "urn:schemas-upnp-org:service:AVTransport:1", + "Stop", + values, + ) + .unwrap(); + + assert!(xml.contains("StopResponse")); + assert!(xml.contains("xmlns:u=\"urn:schemas-upnp-org:service:AVTransport:1\"")); + } +} diff --git a/pmoupnp/src/soap/envelope.rs b/pmoupnp/src/soap/envelope.rs new file mode 100644 index 00000000..3ab19418 --- /dev/null +++ b/pmoupnp/src/soap/envelope.rs @@ -0,0 +1,45 @@ +//! Structures de l'enveloppe SOAP + +use xmltree::Element; + +/// Enveloppe SOAP complète +#[derive(Debug, Clone)] +pub struct SoapEnvelope { + /// En-tête SOAP optionnel + pub header: Option, + + /// Corps SOAP contenant l'action ou la réponse + pub body: SoapBody, +} + +/// En-tête SOAP +#[derive(Debug, Clone)] +pub struct SoapHeader { + /// Contenu XML brut de l'en-tête + pub content: Element, +} + +/// Corps SOAP +#[derive(Debug, Clone)] +pub struct SoapBody { + /// Contenu XML brut du corps + pub content: Element, +} + +impl SoapEnvelope { + /// Crée une nouvelle enveloppe SOAP + pub fn new(body: SoapBody) -> Self { + Self { + header: None, + body, + } + } + + /// Crée une nouvelle enveloppe avec header + pub fn with_header(header: SoapHeader, body: SoapBody) -> Self { + Self { + header: Some(header), + body, + } + } +} diff --git a/pmoupnp/src/soap/fault.rs b/pmoupnp/src/soap/fault.rs new file mode 100644 index 00000000..b79ce855 --- /dev/null +++ b/pmoupnp/src/soap/fault.rs @@ -0,0 +1,173 @@ +//! SOAP Faults pour UPnP + +use xmltree::{Element, XMLNode}; + +/// Erreur SOAP (Fault) +#[derive(Debug, Clone)] +pub struct SoapFault { + /// Code d'erreur (ex: "s:Client", "401") + pub fault_code: String, + + /// Description de l'erreur + pub fault_string: String, + + /// Détails UPnP optionnels + pub upnp_error: Option, +} + +/// Erreur UPnP spécifique +#[derive(Debug, Clone)] +pub struct UpnpError { + /// Code d'erreur UPnP (ex: "401", "501") + pub error_code: String, + + /// Description de l'erreur + pub error_description: String, +} + +impl SoapFault { + /// Crée un fault SOAP simple + pub fn new(fault_code: String, fault_string: String) -> Self { + Self { + fault_code, + fault_string, + upnp_error: None, + } + } + + /// Crée un fault SOAP avec erreur UPnP + pub fn with_upnp_error( + fault_code: String, + fault_string: String, + error_code: String, + error_description: String, + ) -> Self { + Self { + fault_code, + fault_string, + upnp_error: Some(UpnpError { + error_code, + error_description, + }), + } + } +} + +/// Construit un SOAP Fault XML +/// +/// # Arguments +/// +/// * `fault_code` - Code du fault (ex: "s:Client") +/// * `fault_string` - Message d'erreur +/// * `upnp_error_code` - Code d'erreur UPnP optionnel (ex: "401") +/// * `upnp_error_desc` - Description d'erreur UPnP optionnelle +/// +/// # Returns +/// +/// XML SOAP Fault formaté +pub fn build_soap_fault( + fault_code: &str, + fault_string: &str, + upnp_error_code: Option<&str>, + upnp_error_desc: Option<&str>, +) -> Result { + // Construire l'élément Fault + let mut fault = Element::new("s:Fault"); + + // faultcode + let mut faultcode_elem = Element::new("faultcode"); + faultcode_elem + .children + .push(XMLNode::Text(fault_code.to_string())); + fault.children.push(XMLNode::Element(faultcode_elem)); + + // faultstring + let mut faultstring_elem = Element::new("faultstring"); + faultstring_elem + .children + .push(XMLNode::Text(fault_string.to_string())); + fault.children.push(XMLNode::Element(faultstring_elem)); + + // detail (si erreur UPnP) + if let (Some(code), Some(desc)) = (upnp_error_code, upnp_error_desc) { + let mut detail = Element::new("detail"); + + let mut upnp_error = Element::new("UPnPError"); + upnp_error.attributes.insert( + "xmlns".to_string(), + "urn:schemas-upnp-org:control-1-0".to_string(), + ); + + let mut error_code_elem = Element::new("errorCode"); + error_code_elem + .children + .push(XMLNode::Text(code.to_string())); + upnp_error + .children + .push(XMLNode::Element(error_code_elem)); + + let mut error_desc_elem = Element::new("errorDescription"); + error_desc_elem + .children + .push(XMLNode::Text(desc.to_string())); + upnp_error + .children + .push(XMLNode::Element(error_desc_elem)); + + detail.children.push(XMLNode::Element(upnp_error)); + fault.children.push(XMLNode::Element(detail)); + } + + // Construire le Body + let mut body = Element::new("s:Body"); + body.children.push(XMLNode::Element(fault)); + + // 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.children.push(XMLNode::Element(body)); + + // Sérialiser + let mut buf = Vec::new(); + let config = xmltree::EmitterConfig::new() + .perform_indent(true) + .indent_string(" "); + envelope.write_with_config(&mut buf, config)?; + + Ok(String::from_utf8(buf).unwrap()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_simple_fault() { + let xml = build_soap_fault("s:Client", "Invalid Action", None, None).unwrap(); + + assert!(xml.contains("")); + assert!(xml.contains("s:Client")); + assert!(xml.contains("Invalid Action")); + assert!(!xml.contains("UPnPError")); + } + + #[test] + fn test_build_upnp_fault() { + let xml = build_soap_fault( + "s:Client", + "UPnP Error", + Some("401"), + Some("Invalid Action"), + ) + .unwrap(); + + assert!(xml.contains("")); + assert!(xml.contains("")); + assert!(xml.contains("401")); + assert!(xml.contains("Invalid Action")); + } +} diff --git a/pmoupnp/src/soap/mod.rs b/pmoupnp/src/soap/mod.rs new file mode 100644 index 00000000..06dd7868 --- /dev/null +++ b/pmoupnp/src/soap/mod.rs @@ -0,0 +1,89 @@ +//! # Module SOAP - Simple Object Access Protocol +//! +//! Ce module implémente le support SOAP pour UPnP, permettant l'invocation d'actions +//! et la gestion des réponses/erreurs. +//! +//! ## Fonctionnalités +//! +//! - ✅ Parsing d'enveloppes SOAP +//! - ✅ Extraction d'actions UPnP avec arguments +//! - ✅ Construction de réponses SOAP +//! - ✅ Gestion des SOAP Faults +//! - ✅ Support des namespaces UPnP +//! +//! ## Architecture +//! +//! - [`SoapEnvelope`] : Enveloppe SOAP complète +//! - [`SoapAction`] : Action UPnP extraite +//! - [`SoapResponse`] : Réponse UPnP +//! - [`SoapFault`] : Erreur SOAP +//! +//! ## Example +//! +//! ```ignore +//! use pmoupnp::soap::{parse_soap_action, build_soap_response}; +//! +//! // Parser une action SOAP +//! let body = r#" +//! +//! +//! +//! 0 +//! 1 +//! +//! +//! "#; +//! +//! let action = parse_soap_action(body.as_bytes()).unwrap(); +//! assert_eq!(action.name, "Play"); +//! assert_eq!(action.args.get("InstanceID"), Some(&"0".to_string())); +//! +//! // Construire une réponse +//! let mut values = std::collections::HashMap::new(); +//! values.insert("CurrentTrack".to_string(), "5".to_string()); +//! let response = build_soap_response( +//! "urn:schemas-upnp-org:service:AVTransport:1", +//! "GetPositionInfo", +//! values +//! ).unwrap(); +//! ``` + +mod envelope; +mod parser; +mod builder; +mod fault; + +pub use envelope::{SoapEnvelope, SoapHeader, SoapBody}; +pub use parser::{parse_soap_action, SoapAction}; +pub use builder::build_soap_response; +pub use fault::{SoapFault, build_soap_fault}; + +/// Codes d'erreur SOAP UPnP standards +pub mod error_codes { + /// Action invalide + pub const INVALID_ACTION: &str = "401"; + + /// Arguments invalides + pub const INVALID_ARGS: &str = "402"; + + /// Action échouée + pub const ACTION_FAILED: &str = "501"; + + /// Argument manquant + pub const ARGUMENT_VALUE_INVALID: &str = "600"; + + /// Argument hors limites + pub const ARGUMENT_VALUE_OUT_OF_RANGE: &str = "601"; + + /// Action optionnelle non implémentée + pub const OPTIONAL_ACTION_NOT_IMPLEMENTED: &str = "602"; + + /// Mémoire insuffisante + pub const OUT_OF_MEMORY: &str = "603"; + + /// Erreur humaine lisible + pub const HUMAN_INTERVENTION_REQUIRED: &str = "604"; + + /// Argument sous forme de chaîne trop long + pub const STRING_ARGUMENT_TOO_LONG: &str = "605"; +} diff --git a/pmoupnp/src/soap/parser.rs b/pmoupnp/src/soap/parser.rs new file mode 100644 index 00000000..d5d243f9 --- /dev/null +++ b/pmoupnp/src/soap/parser.rs @@ -0,0 +1,149 @@ +//! Parser SOAP pour actions UPnP + +use super::{SoapBody, SoapEnvelope, SoapHeader}; +use std::collections::HashMap; +use std::io::BufReader; +use xmltree::Element; + +/// Action UPnP extraite d'une enveloppe SOAP +#[derive(Debug, Clone)] +pub struct SoapAction { + /// Nom de l'action (ex: "Play", "SetAVTransportURI") + pub name: String, + + /// Namespace de l'action (ex: "urn:schemas-upnp-org:service:AVTransport:1") + pub namespace: Option, + + /// Arguments de l'action + pub args: HashMap, +} + +/// Erreur de parsing SOAP +#[derive(Debug, thiserror::Error)] +pub enum SoapParseError { + #[error("XML parse error: {0}")] + XmlError(#[from] xmltree::ParseError), + + #[error("Missing SOAP Envelope")] + MissingEnvelope, + + #[error("Missing SOAP Body")] + MissingBody, + + #[error("No action found in SOAP Body")] + NoAction, +} + +/// Parse une action SOAP à partir de bytes XML +pub fn parse_soap_action(xml: &[u8]) -> Result { + let envelope = parse_soap_envelope(xml)?; + extract_action_from_body(&envelope.body) +} + +/// Parse une enveloppe SOAP complète +pub fn parse_soap_envelope(xml: &[u8]) -> Result { + let reader = BufReader::new(xml); + let root = Element::parse(reader)?; + + // Vérifier que c'est bien une Envelope + if !root.name.ends_with("Envelope") { + return Err(SoapParseError::MissingEnvelope); + } + + // Extraire Header (optionnel) + let header = root + .get_child("Header") + .or_else(|| root.children.iter().find_map(|n| n.as_element())) + .filter(|e| e.name.ends_with("Header")) + .map(|e| SoapHeader { + content: e.clone(), + }); + + // Extraire Body (obligatoire) + let body_elem = root + .get_child("Body") + .or_else(|| root.children.iter().find_map(|n| { + n.as_element() + .filter(|e| e.name.ends_with("Body")) + })) + .ok_or(SoapParseError::MissingBody)?; + + let body = SoapBody { + content: body_elem.clone(), + }; + + Ok(SoapEnvelope { header, body }) +} + +/// Extrait l'action UPnP du corps SOAP +fn extract_action_from_body(body: &SoapBody) -> Result { + // Le Body contient un élément enfant qui est l'action + // Format: ... + + let action_elem = body + .content + .children + .iter() + .find_map(|n| n.as_element()) + .ok_or(SoapParseError::NoAction)?; + + let name = action_elem.name.clone(); + let namespace = action_elem.namespace.clone(); + + // Extraire les arguments (enfants directs de l'action) + let mut args = HashMap::new(); + for child in &action_elem.children { + if let Some(elem) = child.as_element() { + let arg_name = elem.name.clone(); + let arg_value = elem.get_text().unwrap_or_default().to_string(); + args.insert(arg_name, arg_value); + } + } + + Ok(SoapAction { + name, + namespace, + args, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_action() { + let xml = r#" + + + + 0 + 1 + + +"#; + + let action = parse_soap_action(xml.as_bytes()).unwrap(); + assert_eq!(action.name, "Play"); + assert_eq!( + action.namespace, + Some("urn:schemas-upnp-org:service:AVTransport:1".to_string()) + ); + assert_eq!(action.args.get("InstanceID"), Some(&"0".to_string())); + assert_eq!(action.args.get("Speed"), Some(&"1".to_string())); + } + + #[test] + fn test_parse_action_no_args() { + let xml = r#" + + + + +"#; + + let action = parse_soap_action(xml.as_bytes()).unwrap(); + assert_eq!(action.name, "Stop"); + assert!(action.args.is_empty()); + } +} diff --git a/pmoupnp/src/ssdp/device.rs b/pmoupnp/src/ssdp/device.rs new file mode 100644 index 00000000..2b1af1ec --- /dev/null +++ b/pmoupnp/src/ssdp/device.rs @@ -0,0 +1,58 @@ +//! Représentation d'un device SSDP + +/// Device SSDP avec ses métadonnées pour les annonces +#[derive(Debug, Clone)] +pub struct SsdpDevice { + /// UUID du device (sans le préfixe "uuid:") + pub uuid: String, + + /// Type du device (ex: "urn:schemas-upnp-org:device:MediaRenderer:1") + pub device_type: String, + + /// URL de la description du device + pub location: String, + + /// Identifiant du serveur (ex: "Linux/5.0 UPnP/1.1 PMOMusic/1.0") + pub server: String, + + /// Liste des types de notification (NT) à annoncer + /// Typiquement: [uuid:xxx, device_type, services...] + pub notification_types: Vec, +} + +impl SsdpDevice { + /// Crée un nouveau device SSDP + pub fn new( + uuid: String, + device_type: String, + location: String, + server: String, + ) -> Self { + // Construction automatique des NTs standards + let notification_types = vec![ + format!("uuid:{}", uuid), + "upnp:rootdevice".to_string(), + device_type.clone(), + ]; + + Self { + uuid, + device_type, + location, + server, + notification_types, + } + } + + /// Ajoute un type de notification (ex: pour un service) + pub fn add_notification_type(&mut self, nt: String) { + if !self.notification_types.contains(&nt) { + self.notification_types.push(nt); + } + } + + /// Retourne la liste des types de notification + pub fn get_notification_types(&self) -> &[String] { + &self.notification_types + } +} diff --git a/pmoupnp/src/ssdp/mod.rs b/pmoupnp/src/ssdp/mod.rs new file mode 100644 index 00000000..17e5cb97 --- /dev/null +++ b/pmoupnp/src/ssdp/mod.rs @@ -0,0 +1,38 @@ +//! # Module SSDP - Simple Service Discovery Protocol +//! +//! Ce module implémente le protocole SSDP (Simple Service Discovery Protocol) pour UPnP, +//! permettant la découverte automatique des devices sur le réseau. +//! +//! ## Fonctionnalités +//! +//! - ✅ Envoi de NOTIFY alive/byebye en multicast +//! - ✅ Réponse aux M-SEARCH en unicast +//! - ✅ Gestion multi-devices avec types de notification +//! - ✅ Annonces périodiques automatiques +//! - ✅ Arrêt propre avec byebye +//! +//! ## Architecture +//! +//! - [`SsdpServer`] : Serveur SSDP principal gérant les devices +//! - [`SsdpDevice`] : Représentation d'un device pour SSDP +//! +//! ## Constants SSDP +//! +//! - **Multicast Address**: 239.255.255.250:1900 +//! - **Max-Age**: 1800 secondes (30 minutes) +//! - **Announcement Period**: 900 secondes (15 minutes, Max-Age/2) + +mod device; +mod server; + +pub use device::SsdpDevice; +pub use server::SsdpServer; + +/// Adresse multicast SSDP +pub const SSDP_MULTICAST_ADDR: &str = "239.255.255.250"; + +/// Port SSDP +pub const SSDP_PORT: u16 = 1900; + +/// Durée de validité des annonces (en secondes) +pub const MAX_AGE: u32 = 1800; diff --git a/pmoupnp/src/ssdp/server.rs b/pmoupnp/src/ssdp/server.rs new file mode 100644 index 00000000..fc344487 --- /dev/null +++ b/pmoupnp/src/ssdp/server.rs @@ -0,0 +1,304 @@ +//! Serveur SSDP + +use super::{SsdpDevice, SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE}; +use std::collections::HashMap; +use std::net::{SocketAddr, UdpSocket}; +use std::sync::{Arc, RwLock}; +use std::time::Duration; +use tracing::{error, info, warn}; + +/// Serveur SSDP gérant les annonces et découvertes +pub struct SsdpServer { + /// Devices enregistrés (UUID -> Device) + devices: Arc>>, + + /// Socket UDP pour SSDP + socket: Option>, +} + +impl SsdpServer { + /// Crée un nouveau serveur SSDP + pub fn new() -> Self { + Self { + devices: Arc::new(RwLock::new(HashMap::new())), + socket: None, + } + } + + /// Démarre le serveur SSDP + /// + /// # Returns + /// + /// `Ok(())` si le démarrage a réussi, `Err` sinon + pub fn start(&mut self) -> std::io::Result<()> { + let addr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT); + let socket = UdpSocket::bind(("0.0.0.0", SSDP_PORT))?; + + // Rejoindre le groupe multicast + 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)?; + + let socket = Arc::new(socket); + self.socket = Some(socket.clone()); + + info!("✅ SSDP server started on {}", addr); + + // Lancer les goroutines d'annonces périodiques et d'écoute M-SEARCH + self.start_periodic_announcements(socket.clone()); + self.start_msearch_listener(socket.clone()); + + Ok(()) + } + + /// Ajoute un device et envoie un alive initial + pub fn add_device(&self, device: SsdpDevice) { + let uuid = device.uuid.clone(); + let mut devices = self.devices.write().unwrap(); + devices.insert(uuid.clone(), device.clone()); + drop(devices); + + // Envoyer alive pour tous les NTs + if let Some(ref socket) = self.socket { + for nt in device.get_notification_types() { + self.send_alive(socket, &device, nt); + } + } + } + + /// Supprime un device et envoie un byebye + pub fn remove_device(&self, uuid: &str) { + let mut devices = self.devices.write().unwrap(); + if let Some(device) = devices.remove(uuid) { + drop(devices); + + // Envoyer byebye pour tous les NTs + if let Some(ref socket) = self.socket { + for nt in device.get_notification_types() { + self.send_byebye(socket, &device, nt); + } + } + } + } + + /// Envoie un NOTIFY alive + fn send_alive(&self, socket: &UdpSocket, device: &SsdpDevice, nt: &str) { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let msg = format!( + "NOTIFY * HTTP/1.1\r\n\ + HOST: {}:{}\r\n\ + CACHE-CONTROL: max-age={}\r\n\ + LOCATION: {}\r\n\ + NT: {}\r\n\ + NTS: ssdp:alive\r\n\ + SERVER: {}\r\n\ + USN: {}\r\n\ + \r\n", + SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE, device.location, nt, device.server, usn + ); + + let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT) + .parse() + .unwrap(); + + match socket.send_to(msg.as_bytes(), addr) { + Ok(_) => info!("✅ NOTIFY alive: {} (NT={})", usn, nt), + Err(e) => warn!("❌ Failed to send NOTIFY alive for {}: {}", usn, e), + } + } + + /// Envoie un NOTIFY byebye + fn send_byebye(&self, socket: &UdpSocket, device: &SsdpDevice, nt: &str) { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let msg = format!( + "NOTIFY * HTTP/1.1\r\n\ + HOST: {}:{}\r\n\ + NT: {}\r\n\ + NTS: ssdp:byebye\r\n\ + USN: {}\r\n\ + \r\n", + SSDP_MULTICAST_ADDR, SSDP_PORT, nt, usn + ); + + let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT) + .parse() + .unwrap(); + + match socket.send_to(msg.as_bytes(), addr) { + Ok(_) => info!("👋 NOTIFY byebye: {} (NT={})", usn, nt), + Err(e) => warn!("❌ Failed to send NOTIFY byebye for {}: {}", usn, e), + } + } + + /// Démarre les annonces périodiques (toutes les MAX_AGE/2 secondes) + fn start_periodic_announcements(&self, socket: Arc) { + let devices = Arc::clone(&self.devices); + let period = Duration::from_secs((MAX_AGE / 2) as u64); + + std::thread::spawn(move || { + loop { + std::thread::sleep(period); + + let devices = devices.read().unwrap(); + for device in devices.values() { + for nt in device.get_notification_types() { + Self::send_alive_static(&socket, device, nt); + } + } + } + }); + } + + /// Version statique de send_alive pour les threads + fn send_alive_static(socket: &UdpSocket, device: &SsdpDevice, nt: &str) { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let msg = format!( + "NOTIFY * HTTP/1.1\r\n\ + HOST: {}:{}\r\n\ + CACHE-CONTROL: max-age={}\r\n\ + LOCATION: {}\r\n\ + NT: {}\r\n\ + NTS: ssdp:alive\r\n\ + SERVER: {}\r\n\ + USN: {}\r\n\ + \r\n", + SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE, device.location, nt, device.server, usn + ); + + let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT) + .parse() + .unwrap(); + + match socket.send_to(msg.as_bytes(), addr) { + Ok(_) => info!("✅ NOTIFY alive (periodic): {} (NT={})", usn, nt), + Err(e) => warn!("❌ Failed to send periodic NOTIFY alive for {}: {}", usn, e), + } + } + + /// Démarre l'écoute des M-SEARCH + fn start_msearch_listener(&self, socket: Arc) { + let devices = Arc::clone(&self.devices); + + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match socket.recv_from(&mut buf) { + Ok((n, src)) => { + let data = String::from_utf8_lossy(&buf[..n]); + if data.starts_with("M-SEARCH") { + if let Some(st) = Self::parse_st(&data) { + let devices = devices.read().unwrap(); + for device in devices.values() { + Self::handle_msearch(&socket, &src, &st, device); + } + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // Timeout, continuer + continue; + } + Err(e) => { + warn!("❌ SSDP read error: {}", e); + } + } + } + }); + } + + /// Parse le champ ST d'un M-SEARCH + fn parse_st(data: &str) -> Option { + for line in data.lines() { + if line.to_uppercase().starts_with("ST:") { + let st = line[3..].trim().to_string(); + info!("✅ M-SEARCH received with ST={}", st); + return Some(st); + } + } + None + } + + /// Répond à un M-SEARCH + fn handle_msearch(socket: &UdpSocket, src: &SocketAddr, st: &str, device: &SsdpDevice) { + let mut nts = Vec::new(); + + if st == "ssdp:all" { + nts.extend(device.get_notification_types().iter().cloned()); + } else if device.get_notification_types().contains(&st.to_string()) { + nts.push(st.to_string()); + } else { + return; // Pas de match + } + + for nt in nts { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT"); + + let resp = format!( + "HTTP/1.1 200 OK\r\n\ + CACHE-CONTROL: max-age={}\r\n\ + DATE: {}\r\n\ + EXT:\r\n\ + LOCATION: {}\r\n\ + SERVER: {}\r\n\ + ST: {}\r\n\ + USN: {}\r\n\ + \r\n", + MAX_AGE, date, device.location, device.server, nt, usn + ); + + match socket.send_to(resp.as_bytes(), src) { + Ok(_) => info!( + "📡 M-SEARCH response sent to {} with ST={}\n
\n\n```\n{}\n```\n
\n", + src, nt, resp + ), + Err(e) => warn!("❌ Failed to send M-SEARCH response to {}: {}", src, e), + } + } + } +} + +impl Default for SsdpServer { + fn default() -> Self { + Self::new() + } +} + +impl Drop for SsdpServer { + fn drop(&mut self) { + // Envoyer byebye pour tous les devices + if let Some(ref socket) = self.socket { + info!("✅ Shutting down SSDP server, sending byebye for all devices"); + let devices = self.devices.read().unwrap(); + for device in devices.values() { + for nt in device.get_notification_types() { + self.send_byebye(socket, device, nt); + } + } + } + } +}