Developement de la couche sspd
This commit is contained in:
@@ -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
101
pmoupnp/src/soap/builder.rs
Normal 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\""));
|
||||
}
|
||||
}
|
||||
45
pmoupnp/src/soap/envelope.rs
Normal file
45
pmoupnp/src/soap/envelope.rs
Normal 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
173
pmoupnp/src/soap/fault.rs
Normal 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
89
pmoupnp/src/soap/mod.rs
Normal 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
149
pmoupnp/src/soap/parser.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
58
pmoupnp/src/ssdp/device.rs
Normal file
58
pmoupnp/src/ssdp/device.rs
Normal 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
38
pmoupnp/src/ssdp/mod.rs
Normal 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
304
pmoupnp/src/ssdp/server.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user