Debut d'un contrôle point

This commit is contained in:
2025-11-29 17:51:53 +01:00
parent 0a03f72467
commit 6cbf4791cd
10 changed files with 682 additions and 2 deletions

218
pmocontrol/src/discovery.rs Normal file
View File

@@ -0,0 +1,218 @@
use std::collections::{HashMap, HashSet};
use std::time::SystemTime;
use pmoupnp::ssdp::SsdpEvent;
use crate::model::{MediaServerInfo, RendererInfo};
use crate::registry::DeviceUpdate;
/// État connu pour un endpoint UPnP identifié par son UDN.
#[derive(Clone, Debug)]
pub struct DiscoveredEndpoint {
/// UDN normalisé (ex: "uuid:xxxx", en minuscules).
pub udn: String,
/// Dernière URL de description device (LOCATION SSDP).
pub location: String,
/// Dernier header SERVER vu sur cet endpoint.
pub server_header: String,
/// Dernier max-age indiqué (TTL SSDP).
pub max_age: u32,
/// Date de dernière vue (Now lors du dernier Alive ou SearchResponse).
pub last_seen: SystemTime,
/// Indique si on a vu ce endpoint comme MediaRenderer.
pub seen_as_renderer: bool,
/// Indique si on a vu ce endpoint comme MediaServer.
pub seen_as_server: bool,
/// ST/NT vus (pour debug/diagnostic si utile).
pub types_seen: HashSet<String>,
}
impl DiscoveredEndpoint {
pub fn new(udn: String, location: String, server_header: String, max_age: u32) -> Self {
Self {
udn,
location,
server_header,
max_age,
last_seen: SystemTime::now(),
seen_as_renderer: false,
seen_as_server: false,
types_seen: HashSet::new(),
}
}
pub fn touch(&mut self, location: String, server_header: String, max_age: u32) {
self.location = location;
self.server_header = server_header;
self.max_age = max_age;
self.last_seen = SystemTime::now();
}
}
/// Fournit les descriptions haut niveau à partir dun endpoint découvert.
/// Limplémentation pourra, plus tard, faire un HTTP GET sur `location`
/// et parser la description pour remplir RendererInfo / MediaServerInfo.
pub trait DeviceDescriptionProvider: Send + Sync {
/// Construit un RendererInfo pour cet endpoint, ou None sil
/// ne correspond pas à un renderer audio intéressant.
fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option<RendererInfo>;
/// Construit un MediaServerInfo pour cet endpoint, ou None sil
/// ne correspond pas à un media server (ou pas intéressant).
fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option<MediaServerInfo>;
}
/// Gestionnaire des événements SSDP -> DeviceUpdate.
pub struct DiscoveryManager<P>
where
P: DeviceDescriptionProvider,
{
endpoints: HashMap<String, DiscoveredEndpoint>,
provider: P,
}
impl<P> DiscoveryManager<P>
where
P: DeviceDescriptionProvider,
{
pub fn new(provider: P) -> Self {
Self {
endpoints: HashMap::new(),
provider,
}
}
pub fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec<DeviceUpdate> {
let mut updates = Vec::new();
match event {
SsdpEvent::Alive {
usn,
nt,
location,
server,
max_age,
..
} => {
if let Some(udn) = extract_udn_from_usn(&usn) {
self.handle_alive(udn, nt, location, server, max_age, &mut updates);
}
}
SsdpEvent::SearchResponse {
usn,
st,
location,
server,
max_age,
..
} => {
if let Some(udn) = extract_udn_from_usn(&usn) {
self.handle_search_response(udn, st, location, server, max_age, &mut updates);
}
}
SsdpEvent::ByeBye { usn, nt, .. } => {
if let Some(udn) = extract_udn_from_usn(&usn) {
self.handle_byebye(udn, nt, &mut updates);
}
}
}
updates
}
fn handle_alive(
&mut self,
udn: String,
nt: String,
location: String,
server_header: String,
max_age: u32,
updates: &mut Vec<DeviceUpdate>,
) {
self.update_endpoint(udn, nt, location, server_header, max_age, updates);
}
fn handle_search_response(
&mut self,
udn: String,
st: String,
location: String,
server_header: String,
max_age: u32,
updates: &mut Vec<DeviceUpdate>,
) {
self.update_endpoint(udn, st, location, server_header, max_age, updates);
}
fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec<DeviceUpdate>) {
if let Some(endpoint) = self.endpoints.get(&udn) {
if endpoint.seen_as_renderer {
updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone()));
}
if endpoint.seen_as_server {
updates.push(DeviceUpdate::ServerOfflineByUdn(udn));
}
}
}
fn update_endpoint(
&mut self,
udn: String,
device_type: String,
location: String,
server_header: String,
max_age: u32,
updates: &mut Vec<DeviceUpdate>,
) {
let endpoint = self
.endpoints
.entry(udn.clone())
.or_insert_with({
let udn = udn.clone();
let location = location.clone();
let server_header = server_header.clone();
move || DiscoveredEndpoint::new(udn, location, server_header, max_age)
});
endpoint.touch(location, server_header, max_age);
endpoint.types_seen.insert(device_type.clone());
if is_renderer_type(&device_type) {
endpoint.seen_as_renderer = true;
if let Some(info) = self.provider.build_renderer_info(endpoint) {
updates.push(DeviceUpdate::RendererOnline(info));
}
}
if is_server_type(&device_type) {
endpoint.seen_as_server = true;
if let Some(info) = self.provider.build_server_info(endpoint) {
updates.push(DeviceUpdate::ServerOnline(info));
}
}
}
}
fn extract_udn_from_usn(usn: &str) -> Option<String> {
let lower = usn.trim().to_ascii_lowercase();
if let Some(idx) = lower.find("uuid:") {
let sub = &lower[idx..];
if let Some(end) = sub.find("::") {
Some(sub[..end].to_string())
} else {
Some(sub.to_string())
}
} else {
None
}
}
fn is_renderer_type(t: &str) -> bool {
let t = t.to_ascii_lowercase();
t.contains("urn:schemas-upnp-org:device:mediarenderer:")
}
fn is_server_type(t: &str) -> bool {
let t = t.to_ascii_lowercase();
t.contains("urn:schemas-upnp-org:device:mediaserver:")
}

10
pmocontrol/src/lib.rs Normal file
View File

@@ -0,0 +1,10 @@
pub mod discovery;
pub mod model;
pub mod registry;
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
pub use model::{
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
RendererInfo, RendererProtocol,
};
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};

66
pmocontrol/src/model.rs Normal file
View File

@@ -0,0 +1,66 @@
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct RendererId(pub String);
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct MediaServerId(pub String);
#[derive(Clone, Debug)]
pub enum RendererProtocol {
UpnpAvOnly,
OpenHomeOnly,
Hybrid,
}
#[derive(Clone, Debug, Default)]
pub struct RendererCapabilities {
pub has_avtransport: bool,
pub has_rendering_control: bool,
pub has_connection_manager: bool,
pub has_oh_playlist: bool,
pub has_oh_volume: bool,
pub has_oh_info: bool,
pub has_oh_time: bool,
pub has_oh_radio: bool,
}
#[derive(Clone, Debug)]
pub struct RendererInfo {
pub id: RendererId,
pub udn: String,
pub friendly_name: String,
pub model_name: String,
pub manufacturer: String,
pub protocol: RendererProtocol,
pub capabilities: RendererCapabilities,
pub location: String,
pub server_header: String,
pub online: bool,
pub last_seen: std::time::SystemTime,
pub max_age: u32,
}
#[derive(Clone, Debug, Default)]
pub struct MediaServerCapabilities {
pub has_content_directory: bool,
pub has_connection_manager: bool,
}
#[derive(Clone, Debug)]
pub struct MediaServerInfo {
pub id: MediaServerId,
pub udn: String,
pub friendly_name: String,
pub model_name: String,
pub manufacturer: String,
pub capabilities: MediaServerCapabilities,
pub location: String,
pub server_header: String,
pub online: bool,
pub last_seen: std::time::SystemTime,
pub max_age: u32,
}

117
pmocontrol/src/registry.rs Normal file
View File

@@ -0,0 +1,117 @@
use std::collections::HashMap;
use std::time::SystemTime;
use crate::model::{MediaServerId, MediaServerInfo, RendererId, RendererInfo};
#[derive(Clone, Debug)]
enum DeviceKey {
Renderer(RendererId),
Server(MediaServerId),
}
#[derive(Debug, Default)]
pub struct DeviceRegistry {
renderers: HashMap<RendererId, RendererInfo>,
servers: HashMap<MediaServerId, MediaServerInfo>,
udn_index: HashMap<String, DeviceKey>,
}
pub trait DeviceRegistryRead {
fn list_renderers(&self) -> Vec<RendererInfo>;
fn list_servers(&self) -> Vec<MediaServerInfo>;
fn get_renderer(&self, id: &RendererId) -> Option<RendererInfo>;
fn get_server(&self, id: &MediaServerId) -> Option<MediaServerInfo>;
}
impl DeviceRegistryRead for DeviceRegistry {
fn list_renderers(&self) -> Vec<RendererInfo> {
self.renderers.values().cloned().collect()
}
fn list_servers(&self) -> Vec<MediaServerInfo> {
self.servers.values().cloned().collect()
}
fn get_renderer(&self, id: &RendererId) -> Option<RendererInfo> {
self.renderers.get(id).cloned()
}
fn get_server(&self, id: &MediaServerId) -> Option<MediaServerInfo> {
self.servers.get(id).cloned()
}
}
#[derive(Debug)]
pub enum DeviceUpdate {
RendererOnline(RendererInfo),
RendererOfflineById(RendererId),
RendererOfflineByUdn(String),
ServerOnline(MediaServerInfo),
ServerOfflineById(MediaServerId),
ServerOfflineByUdn(String),
}
impl DeviceRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn apply_update(&mut self, update: DeviceUpdate) {
match update {
DeviceUpdate::RendererOnline(info) => {
let udn = info.udn.to_ascii_lowercase();
let id = info.id.clone();
let mut info = info;
info.online = true;
info.last_seen = SystemTime::now();
self.renderers.insert(id.clone(), info);
self.udn_index.insert(udn, DeviceKey::Renderer(id));
}
DeviceUpdate::RendererOfflineById(id) => {
if let Some(info) = self.renderers.get_mut(&id) {
info.online = false;
info.last_seen = SystemTime::now();
}
}
DeviceUpdate::RendererOfflineByUdn(udn) => {
let lookup = udn.to_ascii_lowercase();
if let Some(DeviceKey::Renderer(id)) = self.udn_index.get(&lookup) {
if let Some(info) = self.renderers.get_mut(id) {
info.online = false;
info.last_seen = SystemTime::now();
}
}
}
DeviceUpdate::ServerOnline(info) => {
let udn = info.udn.to_ascii_lowercase();
let id = info.id.clone();
let mut info = info;
info.online = true;
info.last_seen = SystemTime::now();
self.servers.insert(id.clone(), info);
self.udn_index.insert(udn, DeviceKey::Server(id));
}
DeviceUpdate::ServerOfflineById(id) => {
if let Some(info) = self.servers.get_mut(&id) {
info.online = false;
info.last_seen = SystemTime::now();
}
}
DeviceUpdate::ServerOfflineByUdn(udn) => {
let lookup = udn.to_ascii_lowercase();
if let Some(DeviceKey::Server(id)) = self.udn_index.get(&lookup) {
if let Some(info) = self.servers.get_mut(id) {
info.online = false;
info.last_seen = SystemTime::now();
}
}
}
}
}
}