Developement de la couche sspd

This commit is contained in:
2025-10-06 12:05:56 +02:00
parent 7abc5d4d6a
commit 222152cdf7
9 changed files with 959 additions and 0 deletions

View File

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

101
pmoupnp/src/soap/builder.rs Normal file
View File

@@ -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<String, String>,
) -> Result<String, xmltree::Error> {
// Construire l'élément de réponse
// Format: <u:ActionResponse xmlns:u="service-urn">
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("<Track>5</Track>"));
assert!(xml.contains("<TrackDuration>00:03:45</TrackDuration>"));
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\""));
}
}

View File

@@ -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<SoapHeader>,
/// 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,
}
}
}

173
pmoupnp/src/soap/fault.rs Normal file
View File

@@ -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<UpnpError>,
}
/// 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<String, xmltree::Error> {
// 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("<s:Fault>"));
assert!(xml.contains("<faultcode>s:Client</faultcode>"));
assert!(xml.contains("<faultstring>Invalid Action</faultstring>"));
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("<s:Fault>"));
assert!(xml.contains("<detail>"));
assert!(xml.contains("<UPnPError"));
assert!(xml.contains("<errorCode>401</errorCode>"));
assert!(xml.contains("<errorDescription>Invalid Action</errorDescription>"));
}
}

89
pmoupnp/src/soap/mod.rs Normal file
View File

@@ -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#"<?xml version="1.0"?>
//! <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
//! <s:Body>
//! <u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
//! <InstanceID>0</InstanceID>
//! <Speed>1</Speed>
//! </u:Play>
//! </s:Body>
//! </s:Envelope>"#;
//!
//! 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";
}

149
pmoupnp/src/soap/parser.rs Normal file
View File

@@ -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<String>,
/// Arguments de l'action
pub args: HashMap<String, String>,
}
/// 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<SoapAction, SoapParseError> {
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<SoapEnvelope, SoapParseError> {
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<SoapAction, SoapParseError> {
// Le Body contient un élément enfant qui est l'action
// Format: <u:ActionName xmlns:u="service-urn">...</u:ActionName>
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#"<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<Speed>1</Speed>
</u:Play>
</s:Body>
</s:Envelope>"#;
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#"<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:Stop xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
</s:Body>
</s:Envelope>"#;
let action = parse_soap_action(xml.as_bytes()).unwrap();
assert_eq!(action.name, "Stop");
assert!(action.args.is_empty());
}
}

View File

@@ -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<String>,
}
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
}
}

38
pmoupnp/src/ssdp/mod.rs Normal file
View File

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

304
pmoupnp/src/ssdp/server.rs Normal file
View File

@@ -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<RwLock<HashMap<String, SsdpDevice>>>,
/// Socket UDP pour SSDP
socket: Option<Arc<UdpSocket>>,
}
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<UdpSocket>) {
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<UdpSocket>) {
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<String> {
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<details>\n\n```\n{}\n```\n</details>\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);
}
}
}
}
}