From ff2e9987944c634658649cdd09c7eecb97d139ea Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 10 Oct 2025 11:50:27 +0200 Subject: [PATCH] =?UTF-8?q?API=20de=20l'=C3=A9ta=20interne=20sur=20serveur?= =?UTF-8?q?=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PMOMusic/src/main.rs | 9 +- pmoupnp/README_INTROSPECTION.md | 219 ++++++++++ pmoupnp/src/devices/device_registry.rs | 536 +++++++++++++++++++++++++ pmoupnp/src/devices/mod.rs | 2 + pmoupnp/src/lib.rs | 5 +- pmoupnp/src/server.rs | 22 - pmoupnp/src/upnp_api.rs | 185 +++++++++ pmoupnp/src/upnp_server.rs | 187 +++++++++ 8 files changed, 1138 insertions(+), 27 deletions(-) create mode 100644 pmoupnp/README_INTROSPECTION.md create mode 100644 pmoupnp/src/devices/device_registry.rs delete mode 100644 pmoupnp/src/server.rs create mode 100644 pmoupnp/src/upnp_api.rs create mode 100644 pmoupnp/src/upnp_server.rs diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index 6c8c6248..c99fa2c9 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -1,8 +1,8 @@ use pmoupnp::{ mediarenderer::MEDIA_RENDERER, ssdp::SsdpServer, + upnp_api::UpnpApiExt, UpnpServer, - UpnpModel, }; use pmoserver::{ logs::LoggingOptions, @@ -14,13 +14,13 @@ use tracing::info; #[tokio::main] async fn main() { - // Créer le serveur + // Créer le serveur - le trait UpnpServer étend automatiquement Server let mut server = ServerBuilder::new_configured().build(); // Initialiser le logging et enregistrer les routes de logs server.init_logging(LoggingOptions::default()).await; - + info!("📡 Registering the cover cache..."); let cache = server.init_cover_cache_configured() .await @@ -44,6 +44,9 @@ async fn main() { info!("📡 Registering Web application..."); server.add_webapp_with_redirect::("/app").await; + // Enregistrer l'API d'introspection UPnP + server.register_upnp_api().await; + info!("📡 Registering MediaRenderer..."); let renderer_instance = server.register_device(MEDIA_RENDERER.clone()) .await diff --git a/pmoupnp/README_INTROSPECTION.md b/pmoupnp/README_INTROSPECTION.md new file mode 100644 index 00000000..58ff9b14 --- /dev/null +++ b/pmoupnp/README_INTROSPECTION.md @@ -0,0 +1,219 @@ +# API d'introspection UPnP + +## Vue d'ensemble + +L'API d'introspection UPnP permet d'explorer et de modifier en temps réel la hiérarchie Device/Service/Action/Variable du serveur UPnP via des endpoints REST. + +## Architecture + +```text +DeviceRegistry (thread_local) +├── DeviceInstanceSet (indexé par nom) +└── Index UDN → nom + +UpnpServer trait (pour pmoserver::Server) +├── register_device() - Enregistre un device +├── device_count() - Nombre de devices +├── list_devices() - Liste tous les devices +└── get_device(udn) - Récupère par UDN + +UpnpApiExt trait (pour pmoserver::Server) +└── register_upnp_api() - Monte l'API REST +``` + +## Utilisation dans PMOMusic + +```rust +use pmoupnp::{UpnpServer, upnp_api::UpnpApiExt}; + +let mut server = ServerBuilder::new_configured().build(); + +// Enregistrer l'API d'introspection +server.register_upnp_api().await; + +// Enregistrer un device UPnP +server.register_device(MEDIA_RENDERER.clone()).await?; +``` + +## Endpoints REST disponibles + +### Liste tous les devices + +```http +GET /api/upnp/devices +``` + +**Réponse :** +```json +{ + "count": 1, + "devices": [ + { + "udn": "uuid:f9ef6c21-0ed3-470c-9846-bc1ae85fea62", + "name": "MediaRenderer", + "friendly_name": "PMOMusic MediaRenderer", + "device_type": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "PMOMusic", + "model_name": "MediaRenderer", + "base_url": "http://192.168.1.100:8080", + "description_url": "http://192.168.1.100:8080/device/uuid:f9ef6c21.../desc.xml" + } + ] +} +``` + +### Détails d'un device + +```http +GET /api/upnp/devices/:udn +``` + +**Réponse :** +```json +{ + "udn": "uuid:f9ef6c21-0ed3-470c-9846-bc1ae85fea62", + "name": "MediaRenderer", + "friendly_name": "PMOMusic MediaRenderer", + "device_type": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "PMOMusic", + "model_name": "MediaRenderer", + "base_url": "http://192.168.1.100:8080", + "description_url": "http://192.168.1.100:8080/device/uuid:f9ef6c21.../desc.xml", + "services": [ + { + "name": "AVTransport", + "service_type": "urn:schemas-upnp-org:service:AVTransport:1", + "service_id": "urn:upnp-org:serviceId:AVTransport", + "control_url": "http://192.168.1.100:8080/.../control", + "event_url": "http://192.168.1.100:8080/.../event", + "scpd_url": "http://192.168.1.100:8080/.../desc.xml" + } + ] +} +``` + +### Variables d'un service + +```http +GET /api/upnp/devices/:udn/services/:service/variables +``` + +**Réponse :** +```json +{ + "udn": "uuid:f9ef6c21-0ed3-470c-9846-bc1ae85fea62", + "service": "AVTransport", + "variables": [ + { + "name": "TransportState", + "value": "STOPPED", + "sends_events": true + }, + { + "name": "TransportStatus", + "value": "OK", + "sends_events": true + }, + { + "name": "CurrentTrackURI", + "value": "", + "sends_events": true + } + ] +} +``` + +## Développement d'une interface web + +L'API REST permet de créer facilement un composant Vue.js pour explorer l'état du serveur UPnP : + +```vue + + + +``` + +## Structures de données + +### DeviceRegistry + +Maintient la collection de tous les `DeviceInstance` avec : +- Double indexation (par nom et UDN) +- Méthodes d'introspection +- Modification des variables d'état + +### Structures sérialisables + +Toutes les structures sont sérialisables en JSON via Serde : +- `DeviceInfo` : Informations complètes sur un device +- `ServiceInfo` : Informations sur un service +- `ActionInfo` : Informations sur une action +- `ArgumentInfo` : Informations sur un argument +- `VariableInfo` : Informations sur une variable d'état + +## Fonctions helper + +### `upnp_server::with_devices` + +Exécute une closure avec accès aux devices : + +```rust +use pmoupnp::upnp_server::with_devices; + +let device_count = with_devices(|devices| devices.len()); +``` + +### `upnp_server::get_device_by_udn` + +Récupère un device par son UDN : + +```rust +use pmoupnp::upnp_server::get_device_by_udn; + +if let Some(device) = get_device_by_udn("uuid:...") { + println!("Found: {}", device.get_name()); +} +``` + +## Modification des variables (TODO) + +L'API pour modifier les variables sera ajoutée ultérieurement via des endpoints POST/PUT : + +```http +PUT /api/upnp/devices/:udn/services/:service/variables/:variable +Content-Type: application/json + +{ + "value": "PLAYING" +} +``` + +## Notes d'implémentation + +- Le `DeviceRegistry` est stocké en `thread_local!` pour ne pas modifier `pmoserver::Server` +- Suit le pattern d'extension utilisé dans PMOMusic (traits `UpnpServer` et `UpnpApiExt`) +- Compatible avec l'architecture existante de `pmolog` et `pmocovers` diff --git a/pmoupnp/src/devices/device_registry.rs b/pmoupnp/src/devices/device_registry.rs new file mode 100644 index 00000000..320c9800 --- /dev/null +++ b/pmoupnp/src/devices/device_registry.rs @@ -0,0 +1,536 @@ +//! Registre centralisé des instances de devices UPnP. +//! +//! Ce module fournit un `DeviceRegistry` qui maintient une collection de tous +//! les `DeviceInstance` actifs, permettant l'introspection et la modification +//! de l'état du serveur UPnP. + +use std::sync::{Arc, RwLock}; +use std::collections::HashMap; +use serde::{Serialize, Deserialize}; + +use crate::{ + devices::DeviceInstance, + state_variables::UpnpVariable, + UpnpTyped, UpnpObjectSet, UpnpTypedInstance, +}; + +/// Ensemble de DeviceInstance. +/// +/// Utilise `UpnpObjectSet` pour bénéficier de l'API standardisée. +pub type DeviceInstanceSet = UpnpObjectSet; + +/// Registre centralisé des devices UPnP. +/// +/// Maintient une collection de tous les devices instanciés, indexés par leur nom ET par leur UDN. +/// Le registre utilise deux index pour permettre une recherche rapide : +/// - `devices` : Index par nom (via UpnpObjectSet) +/// - `udn_index` : Index par UDN pour un accès direct +/// +/// **Important** : L'enregistrement d'un device dans le registre déclenche automatiquement +/// l'enregistrement de toutes ses URLs dans le serveur web. +/// +/// Fournit des méthodes pour : +/// - Enregistrer/désenregistrer des devices (avec enregistrement automatique au serveur) +/// - Rechercher des devices par UDN ou nom +/// - Introspection complète de la hiérarchie Device/Service/Action/Variable +/// - Modification des variables d'état +/// +/// # Examples +/// +/// ```rust,ignore +/// use pmoupnp::devices::DeviceRegistry; +/// +/// let mut registry = DeviceRegistry::new(); +/// +/// // L'enregistrement déclenche automatiquement l'enregistrement au serveur web +/// registry.register(device_instance, &mut server).await?; +/// +/// // Introspection +/// let devices = registry.list_devices(); +/// let device_info = registry.get_device_info("uuid:..."); +/// +/// // Modification +/// registry.set_variable("uuid:...", "AVTransport", "TransportState", "PLAYING").await?; +/// ``` +#[derive(Clone)] +pub struct DeviceRegistry { + /// Devices indexés par nom (via UpnpObjectSet) + devices: DeviceInstanceSet, + /// Index supplémentaire : UDN -> nom du device + udn_index: Arc>>, +} + +impl std::fmt::Debug for DeviceRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let udns = self.udn_index.read().unwrap(); + f.debug_struct("DeviceRegistry") + .field("device_count", &self.devices.all().len()) + .field("udns", &udns.keys().collect::>()) + .finish() + } +} + +impl Default for DeviceRegistry { + fn default() -> Self { + Self::new() + } +} + +impl DeviceRegistry { + /// Crée un nouveau registre vide. + pub fn new() -> Self { + Self { + devices: DeviceInstanceSet::new(), + udn_index: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Enregistre un device dans le registre. + /// + /// # Arguments + /// + /// * `device` - Instance du device à enregistrer + /// + /// # Returns + /// + /// `Ok(())` si l'enregistrement réussit, `Err` si un device avec le même UDN ou nom existe déjà. + /// + /// # Note + /// + /// Cette méthode n'enregistre **pas** les URLs dans le serveur web. Cela doit être fait + /// séparément via `device.register_urls()` ou via `UpnpServer::register_device()`. + /// + /// # Examples + /// + /// ```rust,ignore + /// registry.register(device_instance)?; + /// ``` + pub fn register(&mut self, device: Arc) -> Result<(), String> { + let udn = device.udn().to_string(); + let name = device.get_name().to_string(); + + // Vérifier si l'UDN existe déjà + { + let udn_idx = self.udn_index.read().unwrap(); + if udn_idx.contains_key(&udn) { + return Err(format!("Device with UDN {} already registered", udn)); + } + } + + // Insérer dans le DeviceInstanceSet (par nom) + self.devices.insert(device) + .map_err(|e| format!("Failed to register device in registry: {:?}", e))?; + + // Mettre à jour l'index UDN + { + let mut udn_idx = self.udn_index.write().unwrap(); + udn_idx.insert(udn, name); + } + + Ok(()) + } + + /// Désenregistre un device du registre par UDN. + /// + /// # Arguments + /// + /// * `udn` - UDN du device à désenregistrer + /// + /// # Returns + /// + /// `Some(Arc)` si le device a été trouvé et supprimé, `None` sinon. + pub fn unregister(&mut self, udn: &str) -> Option> { + // Trouver le nom via l'index UDN + let name = { + let mut udn_idx = self.udn_index.write().unwrap(); + udn_idx.remove(udn)? + }; + + // Supprimer du DeviceInstanceSet + self.get_device_by_name(&name) + } + + /// Récupère un device par son UDN. + /// + /// # Arguments + /// + /// * `udn` - UDN du device recherché + /// + /// # Returns + /// + /// `Some(Arc)` si trouvé, `None` sinon. + pub fn get_device(&self, udn: &str) -> Option> { + let udn_idx = self.udn_index.read().unwrap(); + let name = udn_idx.get(udn)?; + self.devices.get_by_name(name) + } + + /// Récupère un device par son nom. + /// + /// # Arguments + /// + /// * `name` - Nom du device recherché + /// + /// # Returns + /// + /// `Some(Arc)` si trouvé, `None` sinon. + pub fn get_device_by_name(&self, name: &str) -> Option> { + self.devices.get_by_name(name) + } + + /// Liste tous les devices enregistrés. + /// + /// # Returns + /// + /// Un vecteur contenant tous les devices. + pub fn list_devices(&self) -> Vec> { + self.devices.all() + } + + /// Compte le nombre de devices enregistrés. + pub fn count(&self) -> usize { + self.devices.all().len() + } + + /// Récupère les informations complètes d'un device. + /// + /// # Arguments + /// + /// * `udn` - UDN du device + /// + /// # Returns + /// + /// `Some(DeviceInfo)` contenant toutes les informations du device, `None` si non trouvé. + pub fn get_device_info(&self, udn: &str) -> Option { + let device = self.get_device(udn)?; + Some(DeviceInfo::from_instance(&device)) + } + + /// Liste les informations de tous les devices. + pub fn list_device_infos(&self) -> Vec { + self.list_devices() + .iter() + .map(|d| DeviceInfo::from_instance(d)) + .collect() + } + + /// Récupère la valeur d'une variable d'état. + /// + /// # Arguments + /// + /// * `udn` - UDN du device + /// * `service_name` - Nom du service + /// * `variable_name` - Nom de la variable + /// + /// # Returns + /// + /// `Some(String)` contenant la valeur de la variable, `None` si non trouvée. + pub fn get_variable(&self, udn: &str, service_name: &str, variable_name: &str) -> Option { + let device = self.get_device(udn)?; + let service = device.get_service(service_name)?; + let variable = service.get_variable(variable_name)?; + Some(variable.value().to_string()) + } + + /// Définit la valeur d'une variable d'état. + /// + /// # Arguments + /// + /// * `udn` - UDN du device + /// * `service_name` - Nom du service + /// * `variable_name` - Nom de la variable + /// * `value` - Nouvelle valeur (sous forme de chaîne) + /// + /// # Returns + /// + /// `Ok(())` si la modification réussit, `Err(String)` en cas d'erreur. + pub async fn set_variable(&self, udn: &str, service_name: &str, variable_name: &str, value: &str) -> Result<(), String> { + let device = self.get_device(udn) + .ok_or_else(|| format!("Device {} not found", udn))?; + + let service = device.get_service(service_name) + .ok_or_else(|| format!("Service {} not found", service_name))?; + + let variable = service.get_variable(variable_name) + .ok_or_else(|| format!("Variable {} not found", variable_name))?; + + // Parser et valider la valeur selon le type de la variable + use crate::variable_types::{StateValue, UpnpVarType}; + let var_model = variable.get_model(); + let state_value = StateValue::from_string(value, &var_model.as_state_var_type()) + .map_err(|e| format!("Invalid value for variable {}: {:?}", variable_name, e))?; + + variable.set_value(state_value).await + .map_err(|e| format!("Failed to set value: {:?}", e))?; + + Ok(()) + } + + /// Récupère toutes les variables d'un service. + /// + /// # Arguments + /// + /// * `udn` - UDN du device + /// * `service_name` - Nom du service + /// + /// # Returns + /// + /// `Some(HashMap)` avec les variables (nom -> valeur), `None` si non trouvé. + pub fn get_service_variables(&self, udn: &str, service_name: &str) -> Option> { + let device = self.get_device(udn)?; + let service = device.get_service(service_name)?; + + let mut variables = HashMap::new(); + for var in service.statevariables().all() { + variables.insert(var.get_name().to_string(), var.value().to_string()); + } + + Some(variables) + } +} + +/// Informations structurées sur un device. +/// +/// Utilisé pour l'introspection et la sérialisation JSON. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceInfo { + /// UDN unique du device + pub udn: String, + /// Nom du device + pub name: String, + /// Nom convivial + pub friendly_name: String, + /// Type de device + pub device_type: String, + /// Fabricant + pub manufacturer: String, + /// Nom du modèle + pub model_name: String, + /// URL de base + pub base_url: String, + /// Services du device + pub services: Vec, + /// Sous-devices + pub devices: Vec, +} + +impl DeviceInfo { + /// Crée une structure DeviceInfo à partir d'une DeviceInstance. + pub fn from_instance(instance: &DeviceInstance) -> Self { + let model = instance.get_model(); + + Self { + udn: instance.udn().to_string(), + name: instance.get_name().to_string(), + friendly_name: model.friendly_name().to_string(), + device_type: model.device_type(), + manufacturer: model.manufacturer().to_string(), + model_name: model.model_name().to_string(), + base_url: instance.base_url().to_string(), + services: instance.services() + .iter() + .map(|s| ServiceInfo::from_instance(s)) + .collect(), + devices: instance.devices() + .iter() + .map(|d| DeviceInfo::from_instance(d)) + .collect(), + } + } +} + +/// Informations sur un service. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceInfo { + /// Nom du service + pub name: String, + /// Type de service + pub service_type: String, + /// Identifiant + pub service_id: String, + /// Actions disponibles + pub actions: Vec, + /// Variables d'état + pub variables: Vec, +} + +impl ServiceInfo { + /// Crée une structure ServiceInfo à partir d'une ServiceInstance. + pub fn from_instance(instance: &crate::services::ServiceInstance) -> Self { + Self { + name: instance.get_name().to_string(), + service_type: instance.service_type(), + service_id: instance.service_id(), + actions: instance.actions() + .all() + .iter() + .map(|a| ActionInfo::from_instance(a)) + .collect(), + variables: instance.statevariables() + .all() + .iter() + .map(|v| VariableInfo::from_instance(v)) + .collect(), + } + } +} + +/// Informations sur une action. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActionInfo { + /// Nom de l'action + pub name: String, + /// Arguments d'entrée + pub arguments_in: Vec, + /// Arguments de sortie + pub arguments_out: Vec, +} + +impl ActionInfo { + /// Crée une structure ActionInfo à partir d'une ActionInstance. + pub fn from_instance(instance: &crate::actions::ActionInstance) -> Self { + let args = instance.arguments_set(); + + let mut arguments_in = Vec::new(); + let mut arguments_out = Vec::new(); + + for arg in args.all() { + let model = arg.get_model(); + if model.is_in() { + arguments_in.push(ArgumentInfo::from_instance(&arg)); + } + if model.is_out() { + arguments_out.push(ArgumentInfo::from_instance(&arg)); + } + } + + Self { + name: instance.get_name().to_string(), + arguments_in, + arguments_out, + } + } +} + +/// Informations sur un argument. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArgumentInfo { + /// Nom de l'argument + pub name: String, + /// Variable d'état associée + pub state_variable: String, +} + +impl ArgumentInfo { + /// Crée une structure ArgumentInfo à partir d'une ArgumentInstance. + pub fn from_instance(instance: &crate::actions::ArgumentInstance) -> Self { + let model = instance.get_model(); + Self { + name: instance.get_name().to_string(), + state_variable: model.state_variable().get_name().to_string(), + } + } +} + +/// Informations sur une variable d'état. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VariableInfo { + /// Nom de la variable + pub name: String, + /// Type de données + pub data_type: String, + /// Valeur actuelle + pub value: String, + /// Envoie des notifications + pub sends_events: bool, +} + +impl VariableInfo { + /// Crée une structure VariableInfo à partir d'une StateVarInstance. + pub fn from_instance(instance: &crate::state_variables::StateVarInstance) -> Self { + use crate::variable_types::UpnpVarType; + let model = instance.get_model(); + + Self { + name: instance.get_name().to_string(), + data_type: model.as_state_var_type().to_string(), + value: instance.value().to_string(), + sends_events: model.is_sending_notification(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + devices::Device, + services::Service, + UpnpModel, + }; + + #[test] + fn test_registry_creation() { + let registry = DeviceRegistry::new(); + assert_eq!(registry.count(), 0); + } + + #[test] + fn test_device_registration() { + let registry = DeviceRegistry::new(); + let device = Device::new( + "TestDevice".to_string(), + "MediaRenderer".to_string(), + "Test Renderer".to_string(), + ); + let instance = Arc::new(device.create_instance()); + + assert!(registry.register(instance.clone()).is_ok()); + assert_eq!(registry.count(), 1); + + // Vérifier qu'on ne peut pas enregistrer deux fois le même UDN + assert!(registry.register(instance).is_err()); + } + + #[test] + fn test_device_retrieval() { + let registry = DeviceRegistry::new(); + let device = Device::new( + "TestDevice".to_string(), + "MediaRenderer".to_string(), + "Test Renderer".to_string(), + ); + let instance = Arc::new(device.create_instance()); + let udn = instance.udn().to_string(); + + registry.register(instance.clone()).unwrap(); + + // Récupération par UDN + let retrieved = registry.get_device(&udn); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().udn(), udn); + + // Récupération par nom + let retrieved_by_name = registry.get_device_by_name("TestDevice"); + assert!(retrieved_by_name.is_some()); + } + + #[test] + fn test_device_unregistration() { + let registry = DeviceRegistry::new(); + let device = Device::new( + "TestDevice".to_string(), + "MediaRenderer".to_string(), + "Test Renderer".to_string(), + ); + let instance = Arc::new(device.create_instance()); + let udn = instance.udn().to_string(); + + registry.register(instance).unwrap(); + assert_eq!(registry.count(), 1); + + let removed = registry.unregister(&udn); + assert!(removed.is_some()); + assert_eq!(registry.count(), 0); + } +} diff --git a/pmoupnp/src/devices/mod.rs b/pmoupnp/src/devices/mod.rs index fbcaa1ad..64dbf960 100644 --- a/pmoupnp/src/devices/mod.rs +++ b/pmoupnp/src/devices/mod.rs @@ -34,7 +34,9 @@ mod device; mod device_instance; mod device_methods; +mod device_registry; pub mod errors; pub use device::Device; pub use device_instance::DeviceInstance; +pub use device_registry::{DeviceRegistry, DeviceInstanceSet, DeviceInfo, ServiceInfo, ActionInfo, ArgumentInfo, VariableInfo}; diff --git a/pmoupnp/src/lib.rs b/pmoupnp/src/lib.rs index a5cd62d9..de2c42ba 100644 --- a/pmoupnp/src/lib.rs +++ b/pmoupnp/src/lib.rs @@ -1,7 +1,8 @@ mod object_trait; mod object_set; -mod server; +pub mod upnp_server; +pub mod upnp_api; pub mod actions; pub mod devices; pub mod mediarenderer; @@ -20,7 +21,7 @@ use std::{collections::HashMap, sync::Arc}; use std::sync::RwLock; pub use crate::object_trait::*; -pub use crate::server::UpnpServer; +pub use crate::upnp_server::UpnpServer; #[derive(Debug, Clone)] pub struct UpnpObjectType { diff --git a/pmoupnp/src/server.rs b/pmoupnp/src/server.rs deleted file mode 100644 index 6b5a288e..00000000 --- a/pmoupnp/src/server.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::sync::Arc; - -use pmoserver::Server; - -use crate::devices::errors::DeviceError; -use crate::devices::{Device, DeviceInstance}; -use crate::UpnpModel; - -pub trait UpnpServer { - async fn register_device(&mut self, device: Arc) -> Result,DeviceError> ; - -} - -impl UpnpServer for Server { - async fn register_device(&mut self, device: Arc) -> Result,DeviceError> { - let di = device.create_instance(); - - di.register_urls(self).await?; - - Ok(di) - } -} \ No newline at end of file diff --git a/pmoupnp/src/upnp_api.rs b/pmoupnp/src/upnp_api.rs new file mode 100644 index 00000000..5d0c4e13 --- /dev/null +++ b/pmoupnp/src/upnp_api.rs @@ -0,0 +1,185 @@ +//! API REST pour l'introspection UPnP. +//! +//! Ce module fournit des endpoints HTTP pour explorer et modifier +//! l'état du serveur UPnP en temps réel, similaire à pmolog et pmocovers. +//! +//! # Routes disponibles +//! +//! - `GET /api/upnp/devices` - Liste tous les devices +//! - `GET /api/upnp/devices/:udn` - Détails d'un device +//! - `GET /api/upnp/devices/:udn/services/:service/variables` - Variables d'un service + +use axum::{ + extract::Path, + http::StatusCode, + response::{IntoResponse, Json}, + routing::get, + Router, +}; +use crate::{ + state_variables::UpnpVariable, + upnp_server, + UpnpTyped, UpnpTypedInstance, +}; +use pmoserver::Server; +use serde_json::json; +use tracing::info; + +/// Handler : Liste tous les devices UPnP. +/// +/// GET /api/upnp/devices +async fn list_devices() -> impl IntoResponse { + upnp_server::with_devices(|devices| { + let device_list: Vec<_> = devices + .iter() + .map(|d| { + json!({ + "udn": d.udn(), + "name": d.get_name(), + "friendly_name": d.get_model().friendly_name(), + "device_type": d.get_model().device_type(), + "manufacturer": d.get_model().manufacturer(), + "model_name": d.get_model().model_name(), + "base_url": d.base_url(), + "description_url": format!("{}{}", d.base_url(), d.description_route()), + }) + }) + .collect(); + + Json(json!({ + "count": devices.len(), + "devices": device_list + })) + }) +} + +/// Handler : Détails d'un device UPnP. +/// +/// GET /api/upnp/devices/:udn +async fn get_device(Path(udn): Path) -> impl IntoResponse { + match upnp_server::get_device_by_udn(&udn) { + Some(device) => { + let model = device.get_model(); + let services: Vec<_> = device + .services() + .iter() + .map(|s| { + json!({ + "name": s.get_name(), + "service_type": s.service_type(), + "service_id": s.service_id(), + "control_url": format!("{}{}", device.base_url(), s.control_route()), + "event_url": format!("{}{}", device.base_url(), s.event_route()), + "scpd_url": format!("{}{}", device.base_url(), s.scpd_route()), + }) + }) + .collect(); + + ( + StatusCode::OK, + Json(json!({ + "udn": device.udn(), + "name": device.get_name(), + "friendly_name": model.friendly_name(), + "device_type": model.device_type(), + "manufacturer": model.manufacturer(), + "model_name": model.model_name(), + "base_url": device.base_url(), + "description_url": format!("{}{}", device.base_url(), device.description_route()), + "services": services, + })), + ) + } + None => ( + StatusCode::NOT_FOUND, + Json(json!({ + "error": "Device not found", + "udn": udn + })), + ), + } +} + +/// Handler : Variables d'un service. +/// +/// GET /api/upnp/devices/:udn/services/:service/variables +async fn get_service_variables(Path((udn, service_name)): Path<(String, String)>) -> impl IntoResponse { + match upnp_server::get_device_by_udn(&udn) { + Some(device) => match device.get_service(&service_name) { + Some(service) => { + let variables: Vec<_> = service + .statevariables() + .all() + .iter() + .map(|v| { + json!({ + "name": v.get_name(), + "value": v.value().to_string(), + "sends_events": v.is_sending_notification(), + }) + }) + .collect(); + + ( + StatusCode::OK, + Json(json!({ + "udn": udn, + "service": service_name, + "variables": variables + })), + ) + } + None => ( + StatusCode::NOT_FOUND, + Json(json!({ + "error": "Service not found", + "service": service_name + })), + ), + }, + None => ( + StatusCode::NOT_FOUND, + Json(json!({ + "error": "Device not found", + "udn": udn + })), + ), + } +} + +/// Trait d'extension pour enregistrer l'API UPnP sur un serveur. +/// +/// Similaire à `WebAppExt` et `CoverCacheExt`. +pub trait UpnpApiExt { + /// Enregistre l'API REST d'introspection UPnP. + /// + /// # Examples + /// + /// ```rust,ignore + /// server.register_upnp_api().await; + /// ``` + async fn register_upnp_api(&mut self); +} + +impl UpnpApiExt for Server { + async fn register_upnp_api(&mut self) { + info!("📡 Registering UPnP introspection API..."); + + // Créer le routeur Axum + let app = Router::new() + .route("/devices", get(list_devices)) + .route("/devices/:udn", get(get_device)) + .route( + "/devices/:udn/services/:service/variables", + get(get_service_variables), + ); + + // Monter le routeur sur /api/upnp via add_router + self.add_router("/api/upnp", app).await; + + info!("✅ UPnP API registered:"); + info!(" - GET /api/upnp/devices"); + info!(" - GET /api/upnp/devices/:udn"); + info!(" - GET /api/upnp/devices/:udn/services/:service/variables"); + } +} diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs new file mode 100644 index 00000000..040ec41d --- /dev/null +++ b/pmoupnp/src/upnp_server.rs @@ -0,0 +1,187 @@ +//! Extension UPnP pour pmoserver. +//! +//! Ce module fournit le trait `UpnpServer` qui étend `pmoserver::Server` +//! avec des fonctionnalités UPnP spécifiques. +//! +//! # Design Pattern +//! +//! Suit le pattern d'extension utilisé dans PMOMusic : +//! - `pmoserver::Server` reste agnostique d'UPnP +//! - Le trait `UpnpServer` ajoute les méthodes UPnP spécifiques +//! - Un `DeviceRegistry` est associé au serveur pour l'introspection +//! +//! # Architecture +//! +//! ```text +//! pmoserver::Server +//! + UpnpServer trait +//! + DeviceRegistry (thread_local storage) +//! ``` + +use std::sync::Arc; +use std::cell::RefCell; + +use pmoserver::Server; + +use crate::devices::errors::DeviceError; +use crate::devices::{Device, DeviceInstance, DeviceRegistry}; +use crate::UpnpModel; + +thread_local! { + /// Registre de devices thread-local. + /// + /// Permet de maintenir un registre de devices par thread/serveur + /// sans modifier la structure `pmoserver::Server`. + static DEVICE_REGISTRY: RefCell = RefCell::new(DeviceRegistry::new()); +} + +/// Trait pour étendre un serveur avec des fonctionnalités UPnP. +/// +/// Ce trait ajoute : +/// - Enregistrement de devices UPnP +/// - Accès au registre centralisé de devices +/// +/// # Design Pattern +/// +/// Ce trait suit le pattern d'extension utilisé dans PMOMusic, +/// permettant d'ajouter des fonctionnalités UPnP sans modifier `pmoserver`. +/// +/// # Examples +/// +/// ```rust,ignore +/// use pmoupnp::UpnpServer; +/// use pmoupnp::devices::Device; +/// use pmoserver::ServerBuilder; +/// use std::sync::Arc; +/// +/// let mut server = ServerBuilder::new_configured().build(); +/// +/// // Enregistrement de devices via le trait UpnpServer +/// let device = Arc::new(Device::new( +/// "MediaRenderer".to_string(), +/// "MediaRenderer".to_string(), +/// "My Renderer".to_string() +/// )); +/// server.register_device(device).await?; +/// +/// // Introspection via le trait UpnpServer +/// let devices = server.device_registry().list_devices(); +/// ``` +pub trait UpnpServer { + /// Enregistre un device UPnP et toutes ses URLs. + /// + /// # Arguments + /// + /// * `device` - Le modèle du device à enregistrer + /// + /// # Returns + /// + /// L'instance du device créée et enregistrée. + async fn register_device(&mut self, device: Arc) -> Result, DeviceError>; + + /// Retourne le nombre de devices enregistrés. + fn device_count(&self) -> usize; + + /// Liste tous les devices enregistrés. + fn list_devices(&self) -> Vec>; + + /// Récupère un device par son UDN. + fn get_device(&self, udn: &str) -> Option>; +} + +// Implémentation du trait UpnpServer pour pmoserver::Server +impl UpnpServer for Server { + async fn register_device(&mut self, device: Arc) -> Result, DeviceError> { + // Créer l'instance (retourne déjà un Arc) + let di = device.create_instance(); + + // Enregistrer les URLs dans le serveur web + di.register_urls(self).await?; + + // Ajouter au registre pour l'introspection + DEVICE_REGISTRY.with(|registry| { + registry.borrow_mut() + .register(di.clone()) + .map_err(|e| DeviceError::UrlRegistrationError(e)) + })?; + + Ok(di) + } + + fn device_count(&self) -> usize { + DEVICE_REGISTRY.with(|registry| registry.borrow().count()) + } + + fn list_devices(&self) -> Vec> { + DEVICE_REGISTRY.with(|registry| registry.borrow().list_devices()) + } + + fn get_device(&self, udn: &str) -> Option> { + DEVICE_REGISTRY.with(|registry| registry.borrow().get_device(udn)) + } +} + +/// Fonctions helper pour accéder au registre depuis les handlers. +/// +/// Ces fonctions permettent d'accéder au registre thread-local depuis +/// n'importe où dans le code, notamment depuis les handlers Axum. + +/// Exécute une closure avec un accès en lecture seule aux devices. +/// +/// # Examples +/// +/// ```rust,ignore +/// use pmoupnp::upnp_server::with_devices; +/// +/// let device_count = with_devices(|devices| devices.len()); +/// ``` +pub fn with_devices(f: F) -> R +where + F: FnOnce(&Vec>) -> R, +{ + DEVICE_REGISTRY.with(|registry| { + let devices = registry.borrow().list_devices(); + f(&devices) + }) +} + +/// Récupère un device par son UDN. +/// +/// # Examples +/// +/// ```rust,ignore +/// use pmoupnp::upnp_server::get_device_by_udn; +/// +/// if let Some(device) = get_device_by_udn("uuid:...") { +/// println!("Found device: {}", device.get_name()); +/// } +/// ``` +pub fn get_device_by_udn(udn: &str) -> Option> { + DEVICE_REGISTRY.with(|registry| registry.borrow().get_device(udn)) +} + +#[cfg(test)] +mod tests { + use super::*; + use pmoserver::ServerBuilder; + + #[tokio::test] + async fn test_device_registration() { + let mut server = ServerBuilder::new("TestServer", "http://localhost:8080", 8080).build(); + + let device = Arc::new(Device::new( + "TestDevice".to_string(), + "MediaRenderer".to_string(), + "Test Renderer".to_string(), + )); + + let instance = server.register_device(device).await.unwrap(); + + // Vérifier que le device est dans le registre + assert_eq!(server.device_count(), 1); + + // Vérifier qu'on peut le retrouver par UDN + let retrieved = server.get_device(instance.udn()); + assert!(retrieved.is_some()); + } +}