Merge pull request 'push-qsztxxtruxvo' (#12) from push-qsztxxtruxvo into main
Reviewed-on: #12
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ OLD-GO-CODE/
|
||||
xxx
|
||||
xx
|
||||
all.txt
|
||||
pmo_src.txt
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
devices:
|
||||
mediarenderer:
|
||||
fakerenderer:
|
||||
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
|
||||
mediaserver:
|
||||
qobuz:
|
||||
udn: 28963b75-4c5f-4da7-b10e-ffafd
|
||||
mediarenderer:
|
||||
fakerenderer:
|
||||
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
|
||||
mediaserver:
|
||||
qobuz:
|
||||
udn: 28963b75-4c5f-4da7-b10e-ffafd
|
||||
host:
|
||||
http_port: "8080"
|
||||
http_port: '8080'
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"makefile.configureOnOpen": false,
|
||||
"git.enabled": false
|
||||
"git.enabled": false,
|
||||
"claude-code.environmentVariables": [
|
||||
|
||||
]
|
||||
}
|
||||
2466
Cargo.lock
generated
2466
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["PMOMusic", "pmoupnp"]
|
||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl"]
|
||||
|
||||
190
Makefile
Normal file
190
Makefile
Normal file
@@ -0,0 +1,190 @@
|
||||
# Makefile pour projet Rust + Vue.js
|
||||
# Variables de configuration
|
||||
CARGO = cargo
|
||||
NPM = npm
|
||||
WEBAPP_DIR = pmoupnp/webapp
|
||||
DIST_DIR = $(WEBAPP_DIR)/dist
|
||||
RUST_TARGET = target/release
|
||||
DOC_DIR = target/doc
|
||||
BINARY_NAME = PMOMusic
|
||||
|
||||
# Couleurs pour l'affichage
|
||||
GREEN = \033[0;32m
|
||||
YELLOW = \033[1;33m
|
||||
RED = \033[0;31m
|
||||
NC = \033[0m # No Color
|
||||
|
||||
.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch
|
||||
|
||||
# Cible par défaut
|
||||
all: build
|
||||
|
||||
## help: Affiche cette aide
|
||||
help:
|
||||
@echo "$(GREEN)Commandes disponibles :$(NC)"
|
||||
@sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /'
|
||||
|
||||
## build: Compile tout (Rust + Vue.js)
|
||||
build: webapp release
|
||||
@echo "$(GREEN)✓ Build complet terminé$(NC)"
|
||||
|
||||
## release: Compile le binaire Rust en mode release
|
||||
release:
|
||||
@echo "$(YELLOW)→ Compilation Rust (release)...$(NC)"
|
||||
$(CARGO) build --release
|
||||
@echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)"
|
||||
|
||||
## debug: Compile le binaire Rust en mode debug
|
||||
debug:
|
||||
@echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)"
|
||||
$(CARGO) build
|
||||
@echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)"
|
||||
|
||||
## test: Exécute tous les tests Rust
|
||||
test:
|
||||
@echo "$(YELLOW)→ Exécution des tests Rust...$(NC)"
|
||||
$(CARGO) test --all
|
||||
@echo "$(GREEN)✓ Tests terminés$(NC)"
|
||||
|
||||
## test-doc: Teste les exemples dans la documentation
|
||||
test-doc:
|
||||
@echo "$(YELLOW)→ Test des exemples de documentation...$(NC)"
|
||||
$(CARGO) test --doc
|
||||
@echo "$(GREEN)✓ Tests de documentation terminés$(NC)"
|
||||
|
||||
## doc: Génère et ouvre la documentation Rust
|
||||
doc:
|
||||
@echo "$(YELLOW)→ Génération de la documentation...$(NC)"
|
||||
$(CARGO) doc --no-deps --document-private-items --open
|
||||
@echo "$(GREEN)✓ Documentation générée dans $(DOC_DIR)$(NC)"
|
||||
|
||||
## doc-build: Génère la documentation sans l'ouvrir
|
||||
doc-build:
|
||||
@echo "$(YELLOW)→ Génération de la documentation...$(NC)"
|
||||
$(CARGO) doc --no-deps --document-private-items
|
||||
@echo "$(GREEN)✓ Documentation générée dans $(DOC_DIR)$(NC)"
|
||||
|
||||
## webapp: Compile l'application Vue.js
|
||||
webapp: webapp-install
|
||||
@echo "$(YELLOW)→ Build Vue.js...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) run build
|
||||
@echo "$(GREEN)✓ Application Vue.js compilée dans $(DIST_DIR)$(NC)"
|
||||
|
||||
## webapp-install: Installe les dépendances npm
|
||||
webapp-install:
|
||||
@echo "$(YELLOW)→ Installation des dépendances npm...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) install
|
||||
@echo "$(GREEN)✓ Dépendances npm installées$(NC)"
|
||||
|
||||
## webapp-dev: Lance le serveur de développement Vue.js
|
||||
webapp-dev:
|
||||
@echo "$(YELLOW)→ Démarrage du serveur de dev Vue.js...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) run dev
|
||||
|
||||
## clean: Nettoie les fichiers de build
|
||||
clean:
|
||||
@echo "$(YELLOW)→ Nettoyage...$(NC)"
|
||||
$(CARGO) clean
|
||||
rm -rf $(DIST_DIR)
|
||||
rm -rf $(WEBAPP_DIR)/node_modules
|
||||
@echo "$(GREEN)✓ Nettoyage terminé$(NC)"
|
||||
|
||||
## clean-rust: Nettoie uniquement les builds Rust
|
||||
clean-rust:
|
||||
@echo "$(YELLOW)→ Nettoyage Rust...$(NC)"
|
||||
$(CARGO) clean
|
||||
@echo "$(GREEN)✓ Nettoyage Rust terminé$(NC)"
|
||||
|
||||
## clean-webapp: Nettoie uniquement le build Vue.js
|
||||
clean-webapp:
|
||||
@echo "$(YELLOW)→ Nettoyage Vue.js...$(NC)"
|
||||
rm -rf $(DIST_DIR)
|
||||
@echo "$(GREEN)✓ Nettoyage Vue.js terminé$(NC)"
|
||||
|
||||
## install: Installe le binaire dans ~/.cargo/bin
|
||||
install: release
|
||||
@echo "$(YELLOW)→ Installation du binaire...$(NC)"
|
||||
$(CARGO) install --path .
|
||||
@echo "$(GREEN)✓ $(BINARY_NAME) installé$(NC)"
|
||||
|
||||
## dev: Lance le serveur en mode debug (recompile à chaque changement)
|
||||
dev:
|
||||
@echo "$(YELLOW)→ Démarrage en mode développement...$(NC)"
|
||||
$(CARGO) watch -x run
|
||||
|
||||
## check: Vérifie que le code compile sans générer de binaire
|
||||
check:
|
||||
@echo "$(YELLOW)→ Vérification du code...$(NC)"
|
||||
$(CARGO) check --all
|
||||
@echo "$(GREEN)✓ Code valide$(NC)"
|
||||
|
||||
## fmt: Formate le code Rust
|
||||
fmt:
|
||||
@echo "$(YELLOW)→ Formatage du code...$(NC)"
|
||||
$(CARGO) fmt --all
|
||||
@echo "$(GREEN)✓ Code formaté$(NC)"
|
||||
|
||||
## fmt-check: Vérifie le formatage sans modifier
|
||||
fmt-check:
|
||||
@echo "$(YELLOW)→ Vérification du formatage...$(NC)"
|
||||
$(CARGO) fmt --all -- --check
|
||||
|
||||
## clippy: Exécute clippy (linter Rust)
|
||||
clippy:
|
||||
@echo "$(YELLOW)→ Analyse avec clippy...$(NC)"
|
||||
$(CARGO) clippy --all-targets --all-features -- -D warnings
|
||||
@echo "$(GREEN)✓ Analyse clippy terminée$(NC)"
|
||||
|
||||
## watch: Recompile automatiquement à chaque changement
|
||||
watch:
|
||||
@echo "$(YELLOW)→ Mode watch activé...$(NC)"
|
||||
$(CARGO) watch -x check
|
||||
|
||||
## ci: Exécute toutes les vérifications CI
|
||||
ci: fmt-check clippy test doc-build webapp
|
||||
@echo "$(GREEN)✓ Toutes les vérifications CI passées$(NC)"
|
||||
|
||||
## run: Lance le binaire en mode debug
|
||||
run: debug
|
||||
@echo "$(YELLOW)→ Lancement de l'application...$(NC)"
|
||||
./target/debug/$(BINARY_NAME)
|
||||
|
||||
## run-release: Lance le binaire en mode release
|
||||
run-release: release
|
||||
@echo "$(YELLOW)→ Lancement de l'application (release)...$(NC)"
|
||||
./$(RUST_TARGET)/$(BINARY_NAME)
|
||||
|
||||
## size: Affiche la taille du binaire
|
||||
size:
|
||||
@echo "$(YELLOW)Taille des binaires :$(NC)"
|
||||
@if [ -f "target/debug/$(BINARY_NAME)" ]; then \
|
||||
echo " Debug: $$(du -h target/debug/$(BINARY_NAME) | cut -f1)"; \
|
||||
fi
|
||||
@if [ -f "$(RUST_TARGET)/$(BINARY_NAME)" ]; then \
|
||||
echo " Release: $$(du -h $(RUST_TARGET)/$(BINARY_NAME) | cut -f1)"; \
|
||||
fi
|
||||
|
||||
## deps: Liste les dépendances obsolètes
|
||||
deps:
|
||||
@echo "$(YELLOW)→ Vérification des dépendances...$(NC)"
|
||||
$(CARGO) outdated
|
||||
|
||||
## update: Met à jour les dépendances
|
||||
update:
|
||||
@echo "$(YELLOW)→ Mise à jour des dépendances Rust...$(NC)"
|
||||
$(CARGO) update
|
||||
@echo "$(YELLOW)→ Mise à jour des dépendances npm...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) update
|
||||
@echo "$(GREEN)✓ Dépendances mises à jour$(NC)"
|
||||
|
||||
## bench: Exécute les benchmarks
|
||||
bench:
|
||||
@echo "$(YELLOW)→ Exécution des benchmarks...$(NC)"
|
||||
$(CARGO) bench
|
||||
|
||||
## coverage: Génère un rapport de couverture de code
|
||||
coverage:
|
||||
@echo "$(YELLOW)→ Génération du rapport de couverture...$(NC)"
|
||||
$(CARGO) tarpaulin --out Html --output-dir target/coverage
|
||||
@echo "$(GREEN)✓ Rapport disponible dans target/coverage/index.html$(NC)"
|
||||
|
||||
@@ -4,3 +4,12 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmoupnp = { path = "../pmoupnp"}
|
||||
|
||||
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.20"
|
||||
axum = "0.8.4"
|
||||
serde_json = "1.0.145"
|
||||
|
||||
@@ -1,3 +1,55 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
use pmoupnp::{mediarenderer::avtransport::{actions::SETAVTRANSPORTURI, AVTTRANSPORT}, server::{
|
||||
logs::{log_dump, log_sse, LogState, SseLayer}, ServerBuilder, Webapp
|
||||
}, UpnpObject}; // ton module pmoupnp::server
|
||||
use tracing_subscriber::Registry;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Charger la config
|
||||
|
||||
let mut server = ServerBuilder::new_configured().build();
|
||||
|
||||
// Ajouter des routes
|
||||
server
|
||||
.add_route("/hello", || async {
|
||||
serde_json::json!({"message": "Hello World"})
|
||||
})
|
||||
.await;
|
||||
|
||||
server
|
||||
.add_route("/info", || async {
|
||||
serde_json::json!({"version": "1.0.0"})
|
||||
})
|
||||
.await;
|
||||
|
||||
server.add_spa::<Webapp>("/app").await;
|
||||
|
||||
// Gère la sortie des logs et sur le serveur SSE pour l'interface web et sur la console
|
||||
let log_state = LogState::new(1000);
|
||||
let subscriber = Registry::default()
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_ansi(true), // Couleurs dans le terminal
|
||||
)
|
||||
.with(SseLayer::new(log_state.clone()));
|
||||
tracing::subscriber::set_global_default(subscriber).unwrap();
|
||||
|
||||
server
|
||||
.add_handler_with_state("/log-sse", log_sse, log_state.clone())
|
||||
.await;
|
||||
server
|
||||
.add_handler_with_state("/log-dump", log_dump, log_state.clone())
|
||||
.await;
|
||||
|
||||
server.add_redirect("/", "/app").await;
|
||||
|
||||
info!("{}",AVTTRANSPORT.to_markdown());
|
||||
info!("{}",AVTTRANSPORT.scpd_xml());
|
||||
|
||||
server.start().await;
|
||||
server.wait().await;
|
||||
}
|
||||
|
||||
3168
deps.svg
Normal file
3168
deps.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 182 KiB |
46
doc/traits_object.md
Normal file
46
doc/traits_object.md
Normal file
@@ -0,0 +1,46 @@
|
||||
```mermaid
|
||||
graph TB
|
||||
Clone[Clone<br/><i>std trait</i>]:::stdTrait
|
||||
Debug[Debug<br/><i>std trait</i>]:::stdTrait
|
||||
|
||||
UpnpDeepClone[UpnpDeepClone<br/>deep_clone]:::baseTrait
|
||||
|
||||
UpnpObject[UpnpObject<br/>to_xml_element<br/>to_xml<br/>to_markdown]:::baseTrait
|
||||
|
||||
UpnpModel[UpnpModel<br/>create_instance]:::derived1
|
||||
UpnpInstance[UpnpInstance<br/>new]:::derived1
|
||||
UpnpTyped[UpnpTyped<br/>get_name<br/>get_object_type]:::derived1
|
||||
UpnpSet[UpnpSet<br/>is_set]:::derived1
|
||||
|
||||
UpnpTypedObject[UpnpTypedObject<br/><i>marker</i>]:::derived2
|
||||
|
||||
UpnpTypedInstance[UpnpTypedInstance<br/><i>marker</i>]:::derived3
|
||||
UpnpModelSet[UpnpModelSet<br/><i>marker</i>]:::derived3
|
||||
UpnInstanceSet[UpnInstanceSet<br/><i>marker</i>]:::derived3
|
||||
|
||||
Clone --> UpnpObject
|
||||
Debug --> UpnpObject
|
||||
|
||||
UpnpObject --> UpnpModel
|
||||
UpnpObject --> UpnpInstance
|
||||
UpnpObject --> UpnpTyped
|
||||
UpnpObject --> UpnpSet
|
||||
|
||||
UpnpObject --> UpnpTypedObject
|
||||
UpnpTyped --> UpnpTypedObject
|
||||
|
||||
UpnpTypedObject --> UpnpTypedInstance
|
||||
UpnpInstance --> UpnpTypedInstance
|
||||
|
||||
UpnpSet --> UpnpModelSet
|
||||
UpnpModel --> UpnpModelSet
|
||||
|
||||
UpnpSet --> UpnInstanceSet
|
||||
UpnpInstance --> UpnInstanceSet
|
||||
|
||||
classDef stdTrait fill:#e1f5ff,stroke:#01579b,stroke-width:2px
|
||||
classDef baseTrait fill:#fff3e0,stroke:#e65100,stroke-width:2px
|
||||
classDef derived1 fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
|
||||
classDef derived2 fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px
|
||||
classDef derived3 fill:#fce4ec,stroke:#880e4f,stroke-width:2px
|
||||
```
|
||||
1
pmoconfig/.gitignore
vendored
Normal file
1
pmoconfig/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
17
pmoconfig/Cargo.toml
Normal file
17
pmoconfig/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
[package]
|
||||
name = "pmoconfig"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
pmoutils ={ path = "../pmoutils" }
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9.33"
|
||||
lazy_static = "1.4.0"
|
||||
dirs = "6.0.0"
|
||||
log = "0.4.20"
|
||||
anyhow = "1.0.75"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
tracing = "0.1.41"
|
||||
319
pmoconfig/src/lib.rs
Normal file
319
pmoconfig/src/lib.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use dirs::home_dir;
|
||||
use lazy_static::lazy_static;
|
||||
use pmoutils::guess_local_ip;
|
||||
use serde_yaml::{Mapping, Value};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
// Configuration par défaut intégrée
|
||||
const DEFAULT_CONFIG: &str = include_str!("pmomusic.yaml");
|
||||
|
||||
lazy_static! {
|
||||
static ref CONFIG: Arc<Config> =
|
||||
Arc::new(Config::load_config("").expect("Failed to load PMOMusic configuration"));
|
||||
}
|
||||
|
||||
const ENV_CONFIG_FILE: &str = "PMOMUSIC_CONFIG";
|
||||
const ENV_PREFIX: &str = "PMOMUSIC_CONFIG__";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
path: String,
|
||||
data: Mutex<Value>,
|
||||
}
|
||||
|
||||
// Implémentation manuelle de Clone
|
||||
impl Clone for Config {
|
||||
fn clone(&self) -> Self {
|
||||
let data = self.data.lock().unwrap().clone();
|
||||
Self {
|
||||
path: self.path.clone(),
|
||||
data: Mutex::new(data),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load_config(filename: &str) -> Result<Self> {
|
||||
let mut path = filename.to_string();
|
||||
let mut data: Option<Vec<u8>> = None;
|
||||
|
||||
// Essayer de charger depuis différents emplacements
|
||||
if !filename.is_empty() {
|
||||
info!(config_file=%path, "Trying to load config");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
if let Ok(env_path) = env::var(ENV_CONFIG_FILE) {
|
||||
info!(env_var=ENV_CONFIG_FILE, path=%env_path, "Trying to load config from env");
|
||||
path = env_path.clone();
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file from env var");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
path = current_dir
|
||||
.join(".pmomusic.yml")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
info!(config_file=%path, "Trying to load config file from current directory");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file in current dir");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
path = Self::get_home_yml_path();
|
||||
info!(config_file=%path, "Trying to load config file from home directory");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file in home directory");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let yaml_data = if let Some(d) = data {
|
||||
d
|
||||
} else {
|
||||
info!("Using default embedded config");
|
||||
DEFAULT_CONFIG.as_bytes().to_vec()
|
||||
};
|
||||
|
||||
let mut config_value: Value = serde_yaml::from_slice(&yaml_data)?;
|
||||
config_value = Self::lower_keys_value(config_value);
|
||||
Self::apply_env_overrides(&mut config_value);
|
||||
|
||||
if path.is_empty() || !Self::is_writable(&path) {
|
||||
let candidates = [
|
||||
filename.to_string(),
|
||||
env::var(ENV_CONFIG_FILE).unwrap_or_default(),
|
||||
".pmomusic.yml".to_string(),
|
||||
Self::get_home_yml_path(),
|
||||
];
|
||||
for candidate in candidates.iter().filter(|c| !c.is_empty()) {
|
||||
if Self::is_writable(candidate) {
|
||||
path = candidate.clone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
return Err(anyhow!("Cannot find a place to store config file"));
|
||||
}
|
||||
|
||||
info!(config_file=%path, "Config file will be stored here");
|
||||
|
||||
let config = Config {
|
||||
path,
|
||||
data: Mutex::new(config_value),
|
||||
};
|
||||
config.save()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let data = self.data.lock().unwrap();
|
||||
let yaml = serde_yaml::to_string(&*data)?;
|
||||
fs::write(&self.path, yaml)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_value(&self, path: &[&str], value: Value) -> Result<()> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
Self::set_value_internal(&mut data, path, value.clone())?;
|
||||
drop(data);
|
||||
self.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_value_internal(data: &mut Value, path: &[&str], value: Value) -> Result<()> {
|
||||
if path.is_empty() {
|
||||
*data = value;
|
||||
return Ok(());
|
||||
}
|
||||
if let Value::Mapping(map) = data {
|
||||
let key = path[0].to_lowercase();
|
||||
let key_value = Value::String(key.clone());
|
||||
if path.len() == 1 {
|
||||
map.insert(key_value, value);
|
||||
} else {
|
||||
let entry = map
|
||||
.entry(key_value)
|
||||
.or_insert(Value::Mapping(Mapping::new()));
|
||||
Self::set_value_internal(entry, &path[1..], value)?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Current node is not a map"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_value(&self, path: &[&str]) -> Result<Value> {
|
||||
let data = self.data.lock().unwrap();
|
||||
Self::get_value_internal(&data, path)
|
||||
}
|
||||
|
||||
fn get_value_internal(data: &Value, path: &[&str]) -> Result<Value> {
|
||||
let mut current = data;
|
||||
for (i, key) in path.iter().enumerate() {
|
||||
if let Value::Mapping(map) = current {
|
||||
let key = key.to_lowercase();
|
||||
if let Some(next) = map.get(&Value::String(key)) {
|
||||
current = next;
|
||||
} else {
|
||||
return Err(anyhow!("Path {} does not exist", path[..=i].join(".")));
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow!("Path {} is not a Config", path[..i].join(".")));
|
||||
}
|
||||
}
|
||||
Ok(current.clone())
|
||||
}
|
||||
|
||||
fn get_home_yml_path() -> String {
|
||||
home_dir()
|
||||
.map(|p| p.join(".pmomusic.yml"))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn apply_env_overrides(config: &mut Value) {
|
||||
for (key, value) in env::vars() {
|
||||
if key.starts_with(ENV_PREFIX) {
|
||||
let key_path = key
|
||||
.trim_start_matches(ENV_PREFIX)
|
||||
.split("__")
|
||||
.collect::<Vec<_>>();
|
||||
let yaml_value = Self::convert_env_value(&value);
|
||||
let _ = Self::set_value_internal(config, &key_path, yaml_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_env_value(value: &str) -> Value {
|
||||
if let Ok(parsed) = serde_yaml::from_str::<Value>(value) {
|
||||
return parsed;
|
||||
}
|
||||
Value::String(value.to_string())
|
||||
}
|
||||
|
||||
fn lower_keys_value(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Mapping(map) => {
|
||||
let mut new_map = Mapping::new();
|
||||
for (k, v) in map {
|
||||
if let Value::String(s) = k {
|
||||
let new_key = Value::String(s.to_lowercase());
|
||||
let new_val = Self::lower_keys_value(v);
|
||||
new_map.insert(new_key, new_val);
|
||||
} else {
|
||||
new_map.insert(k, Self::lower_keys_value(v));
|
||||
}
|
||||
}
|
||||
Value::Mapping(new_map)
|
||||
}
|
||||
Value::Sequence(seq) => {
|
||||
Value::Sequence(seq.into_iter().map(Self::lower_keys_value).collect())
|
||||
}
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_writable(path: &str) -> bool {
|
||||
let path = Path::new(path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::metadata(parent)
|
||||
.map(|m| !m.permissions().readonly())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_base_url(&self) -> String {
|
||||
match self.get_value(&["host", "base_url"]) {
|
||||
Ok(Value::String(s)) if !s.is_empty() => s,
|
||||
Ok(_) => {
|
||||
tracing::warn!("Base URL is not a string or empty, using default localhost");
|
||||
guess_local_ip()
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to get base URL: {}, using default localhost", err);
|
||||
guess_local_ip()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_http_port(&self) -> u16 {
|
||||
match self.get_value(&["host", "http_port"]) {
|
||||
Ok(Value::Number(n)) if n.is_i64() => n.as_i64().unwrap() as u16,
|
||||
Ok(Value::String(s)) => match s.parse::<u16>() {
|
||||
Ok(port) => port,
|
||||
Err(_) => {
|
||||
tracing::warn!("Invalid HTTP port '{}', using default 8080", s);
|
||||
8080
|
||||
}
|
||||
},
|
||||
Ok(_) => {
|
||||
tracing::warn!("HTTP port not a number or string, using default 8080");
|
||||
8080
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to get HTTP port: {}, using default 8080", err);
|
||||
8080
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_device_udn(&self, devtype: &str, name: &str) -> Result<String> {
|
||||
let path = &["devices", devtype, name, "udn"];
|
||||
match self.get_value(path) {
|
||||
Ok(Value::String(udn)) => Ok(udn),
|
||||
_ => {
|
||||
let new_udn = Uuid::new_v4().to_string();
|
||||
self.set_value(path, Value::String(new_udn.clone()))?;
|
||||
Ok(new_udn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cover_cache_dir(&self) -> Result<String> {
|
||||
match self.get_value(&["host", "cover_cache", "directory"])? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Ok("./.pmomusic_covers".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cover_cache_size(&self) -> Result<usize> {
|
||||
match self.get_value(&["host", "cover_cache", "size"])? {
|
||||
Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
|
||||
Value::Number(n) if n.is_u64() => Ok(n.as_u64().unwrap() as usize),
|
||||
_ => Ok(2000),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne l'instance globale
|
||||
pub fn get_config() -> Arc<Config> {
|
||||
CONFIG.clone()
|
||||
}
|
||||
11
pmoconfig/src/pmomusic.yaml
Normal file
11
pmoconfig/src/pmomusic.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
host:
|
||||
http_port: "8080"
|
||||
cover_cache:
|
||||
directory: "./.pmomusic_covers"
|
||||
size: 2000
|
||||
devices:
|
||||
mediarenderer:
|
||||
mpd_renderer:
|
||||
mediaserver:
|
||||
qobuz:
|
||||
udn: "uuid:28963b75-4c5f-4da7-b10e-ffafd"
|
||||
12
pmodidl/Cargo.toml
Normal file
12
pmodidl/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "pmodidl"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = "1.0.228"
|
||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
||||
quick-xml = { version = "0.38.3", features = ["serialize"] }
|
||||
bevy_reflect = "0.17.1"
|
||||
bevy_reflect_derive = "0.17.1"
|
||||
580
pmodidl/src/lib.rs
Normal file
580
pmodidl/src/lib.rs
Normal file
@@ -0,0 +1,580 @@
|
||||
//! # pmodidl - DIDL-Lite Parser
|
||||
//!
|
||||
//! Parser et utilitaires pour le format DIDL-Lite utilisé dans UPnP/DLNA.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Write;
|
||||
use bevy_reflect::Reflect;
|
||||
|
||||
// ============= Couche d'abstraction générique =============
|
||||
|
||||
/// Trait pour tout parser de métadonnées média
|
||||
pub trait MediaMetadataParser: Sized {
|
||||
type Error: std::error::Error + Send + Sync + 'static;
|
||||
|
||||
/// Parse une chaîne de métadonnées
|
||||
fn parse(input: &str) -> Result<Self, Self::Error>;
|
||||
|
||||
/// Retourne le format du parser
|
||||
fn format_name() -> &'static str;
|
||||
}
|
||||
|
||||
/// Enveloppe générique pour tout type de métadonnées parsées
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Reflect)]
|
||||
pub struct ParsedMetadata<T> {
|
||||
/// Format du document (ex: "DIDL-Lite", "RSS", etc.)
|
||||
pub format: String,
|
||||
|
||||
/// Données parsées
|
||||
pub data: T,
|
||||
|
||||
/// Timestamp du parsing (exclu de la réflexion car SystemTime n'implémente pas Reflect)
|
||||
#[reflect(ignore)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parsed_at: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
impl<T> ParsedMetadata<T> {
|
||||
pub fn new(format: impl Into<String>, data: T) -> Self {
|
||||
Self {
|
||||
format: format.into(),
|
||||
data,
|
||||
parsed_at: Some(std::time::SystemTime::now()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforme les données avec une fonction
|
||||
pub fn map<U, F>(self, f: F) -> ParsedMetadata<U>
|
||||
where
|
||||
F: FnOnce(T) -> U,
|
||||
{
|
||||
ParsedMetadata {
|
||||
format: self.format,
|
||||
data: f(self.data),
|
||||
parsed_at: self.parsed_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fonction helper pour parser et envelopper automatiquement
|
||||
pub fn parse_metadata<P: MediaMetadataParser>(input: &str) -> Result<ParsedMetadata<P>, P::Error> {
|
||||
let data = P::parse(input)?;
|
||||
Ok(ParsedMetadata::new(P::format_name(), data))
|
||||
}
|
||||
|
||||
// ============= Implémentation pour DIDLLite =============
|
||||
|
||||
impl MediaMetadataParser for DIDLLite {
|
||||
type Error = quick_xml::de::DeError;
|
||||
|
||||
fn parse(input: &str) -> Result<Self, Self::Error> {
|
||||
quick_xml::de::from_str(input)
|
||||
}
|
||||
|
||||
fn format_name() -> &'static str {
|
||||
"DIDL-Lite"
|
||||
}
|
||||
}
|
||||
|
||||
/// Type alias pour faciliter l'utilisation
|
||||
pub type DidlMetadata = ParsedMetadata<DIDLLite>;
|
||||
|
||||
// ============= Structures DIDL-Lite =============
|
||||
|
||||
|
||||
/// Racine d'un document DIDL-Lite
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
||||
#[serde(rename = "DIDL-Lite")]
|
||||
pub struct DIDLLite {
|
||||
#[serde(rename = "@xmlns")]
|
||||
pub xmlns: String,
|
||||
|
||||
#[serde(rename = "@xmlns:upnp", skip_serializing_if = "Option::is_none")]
|
||||
pub xmlns_upnp: Option<String>,
|
||||
|
||||
#[serde(rename = "@xmlns:dc", skip_serializing_if = "Option::is_none")]
|
||||
pub xmlns_dc: Option<String>,
|
||||
|
||||
#[serde(rename = "@xmlns:dlna", skip_serializing_if = "Option::is_none")]
|
||||
pub xmlns_dlna: Option<String>,
|
||||
|
||||
#[serde(rename = "@xmlns:sec", skip_serializing_if = "Option::is_none")]
|
||||
pub xmlns_sec: Option<String>,
|
||||
|
||||
#[serde(rename = "@xmlns:pv", skip_serializing_if = "Option::is_none")]
|
||||
pub xmlns_pv: Option<String>,
|
||||
|
||||
#[serde(rename = "container", default)]
|
||||
pub containers: Vec<Container>,
|
||||
|
||||
#[serde(rename = "item", default)]
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
/// Container pouvant contenir d'autres containers ou items
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
||||
pub struct Container {
|
||||
#[serde(rename = "@id")]
|
||||
pub id: String,
|
||||
|
||||
#[serde(rename = "@parentID")]
|
||||
pub parent_id: String,
|
||||
|
||||
#[serde(rename = "@restricted", skip_serializing_if = "Option::is_none")]
|
||||
pub restricted: Option<String>,
|
||||
|
||||
#[serde(rename = "@childCount", skip_serializing_if = "Option::is_none")]
|
||||
pub child_count: Option<String>,
|
||||
|
||||
#[serde(rename = "dc:title", alias = "title")]
|
||||
pub title: String,
|
||||
|
||||
#[serde(rename = "upnp:class", alias = "class")]
|
||||
pub class: String,
|
||||
|
||||
#[serde(rename = "container", default)]
|
||||
pub containers: Vec<Container>,
|
||||
|
||||
#[serde(rename = "item", default)]
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
/// Item représentant un objet audio
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
||||
pub struct Item {
|
||||
#[serde(rename = "@id")]
|
||||
pub id: String,
|
||||
|
||||
#[serde(rename = "@parentID")]
|
||||
pub parent_id: String,
|
||||
|
||||
#[serde(rename = "@restricted", skip_serializing_if = "Option::is_none")]
|
||||
pub restricted: Option<String>,
|
||||
|
||||
#[serde(rename = "dc:title", alias = "title")]
|
||||
pub title: String,
|
||||
|
||||
#[serde(rename = "dc:creator", alias = "creator", skip_serializing_if = "Option::is_none")]
|
||||
pub creator: Option<String>,
|
||||
|
||||
#[serde(rename = "upnp:class", alias = "class")]
|
||||
pub class: String,
|
||||
|
||||
#[serde(rename = "upnp:artist", alias = "artist", skip_serializing_if = "Option::is_none")]
|
||||
pub artist: Option<String>,
|
||||
|
||||
#[serde(rename = "upnp:album", alias = "album", skip_serializing_if = "Option::is_none")]
|
||||
pub album: Option<String>,
|
||||
|
||||
#[serde(rename = "upnp:genre", alias = "genre", skip_serializing_if = "Option::is_none")]
|
||||
pub genre: Option<String>,
|
||||
|
||||
#[serde(rename = "upnp:albumArtURI", alias = "albumArtURI", skip_serializing_if = "Option::is_none")]
|
||||
pub album_art: Option<String>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub album_art_pk: Option<String>,
|
||||
|
||||
#[serde(rename = "dc:date", alias = "date", skip_serializing_if = "Option::is_none")]
|
||||
pub date: Option<String>,
|
||||
|
||||
#[serde(rename = "upnp:originalTrackNumber", alias = "originalTrackNumber", skip_serializing_if = "Option::is_none")]
|
||||
pub original_track_number: Option<String>,
|
||||
|
||||
#[serde(rename = "res", default)]
|
||||
pub resources: Vec<Resource>,
|
||||
|
||||
#[serde(rename = "desc", default)]
|
||||
pub descriptions: Vec<Description>,
|
||||
}
|
||||
|
||||
/// Ressource média (fichier audio)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
||||
pub struct Resource {
|
||||
#[serde(rename = "@protocolInfo")]
|
||||
pub protocol_info: String,
|
||||
|
||||
#[serde(rename = "@bitsPerSample", skip_serializing_if = "Option::is_none")]
|
||||
pub bits_per_sample: Option<String>,
|
||||
|
||||
#[serde(rename = "@sampleFrequency", skip_serializing_if = "Option::is_none")]
|
||||
pub sample_frequency: Option<String>,
|
||||
|
||||
#[serde(rename = "@nrAudioChannels", skip_serializing_if = "Option::is_none")]
|
||||
pub nr_audio_channels: Option<String>,
|
||||
|
||||
#[serde(rename = "@duration", skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<String>,
|
||||
|
||||
#[serde(rename = "$text")]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Description avec métadonnées additionnelles (replaygain, etc.)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
||||
pub struct Description {
|
||||
#[serde(rename = "@id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
|
||||
#[serde(rename = "@nameSpace", skip_serializing_if = "Option::is_none")]
|
||||
pub namespace: Option<String>,
|
||||
|
||||
#[serde(rename = "track_gain", skip_serializing_if = "Option::is_none")]
|
||||
pub track_gain: Option<String>,
|
||||
|
||||
#[serde(rename = "track_peak", skip_serializing_if = "Option::is_none")]
|
||||
pub track_peak: Option<String>,
|
||||
}
|
||||
|
||||
// ============= Implémentation des méthodes =============
|
||||
|
||||
impl DIDLLite {
|
||||
/// Itère sur tous les containers de manière récursive
|
||||
pub fn all_containers(&self) -> impl Iterator<Item = &Container> {
|
||||
AllContainersIter::new(&self.containers)
|
||||
}
|
||||
|
||||
/// Itère sur tous les items de manière récursive
|
||||
pub fn all_items(&self) -> impl Iterator<Item = &Item> {
|
||||
AllItemsIter::new(&self.containers, &self.items)
|
||||
}
|
||||
|
||||
/// Trouve un container par ID
|
||||
pub fn get_container_by_id(&self, id: &str) -> Option<&Container> {
|
||||
self.all_containers().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// Trouve un item par ID
|
||||
pub fn get_item_by_id(&self, id: &str) -> Option<&Item> {
|
||||
self.all_items().find(|i| i.id == id)
|
||||
}
|
||||
|
||||
/// Filtre les containers
|
||||
pub fn filter_containers<F>(&self, predicate: F) -> impl Iterator<Item = &Container>
|
||||
where
|
||||
F: Fn(&Container) -> bool,
|
||||
{
|
||||
self.all_containers().filter(move |c| predicate(c))
|
||||
}
|
||||
|
||||
/// Filtre les items
|
||||
pub fn filter_items<F>(&self, predicate: F) -> impl Iterator<Item = &Item>
|
||||
where
|
||||
F: Fn(&Item) -> bool,
|
||||
{
|
||||
self.all_items().filter(move |i| predicate(i))
|
||||
}
|
||||
|
||||
/// Génère une représentation Markdown
|
||||
pub fn to_markdown(&self) -> String {
|
||||
let mut buf = String::new();
|
||||
buf.push_str("### DIDL-Lite Document\n\n");
|
||||
|
||||
if !self.containers.is_empty() {
|
||||
buf.push_str("#### Containers\n\n");
|
||||
for container in &self.containers {
|
||||
container.write_markdown(&mut buf, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.items.is_empty() {
|
||||
buf.push_str("#### Items\n\n");
|
||||
for item in &self.items {
|
||||
item.write_markdown(&mut buf, 0);
|
||||
}
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
impl Container {
|
||||
/// Itère sur tous les containers enfants récursivement
|
||||
pub fn all_containers(&self) -> impl Iterator<Item = &Container> {
|
||||
AllContainersIter::new(&self.containers)
|
||||
}
|
||||
|
||||
/// Itère sur tous les items de ce container et ses enfants
|
||||
pub fn all_items(&self) -> impl Iterator<Item = &Item> {
|
||||
AllItemsIter::new(&self.containers, &self.items)
|
||||
}
|
||||
|
||||
fn write_markdown(&self, buf: &mut String, depth: usize) {
|
||||
let indent = " ".repeat(depth);
|
||||
|
||||
writeln!(buf, "{}- **Container**: {}", indent, self.title).unwrap();
|
||||
writeln!(buf, "{} - ID: `{}`", indent, self.id).unwrap();
|
||||
writeln!(buf, "{} - ParentID: `{}`", indent, self.parent_id).unwrap();
|
||||
writeln!(buf, "{} - Class: `{}`", indent, self.class).unwrap();
|
||||
|
||||
if let Some(ref restricted) = self.restricted {
|
||||
writeln!(buf, "{} - Restricted: `{}`", indent, restricted).unwrap();
|
||||
}
|
||||
if let Some(ref count) = self.child_count {
|
||||
writeln!(buf, "{} - ChildCount: `{}`", indent, count).unwrap();
|
||||
}
|
||||
|
||||
if !self.containers.is_empty() {
|
||||
writeln!(buf, "{} - Subcontainers:", indent).unwrap();
|
||||
for sub in &self.containers {
|
||||
sub.write_markdown(buf, depth + 2);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.items.is_empty() {
|
||||
writeln!(buf, "{} - Items:", indent).unwrap();
|
||||
for item in &self.items {
|
||||
item.write_markdown(buf, depth + 2);
|
||||
}
|
||||
}
|
||||
|
||||
buf.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Itère sur les ressources audio uniquement
|
||||
pub fn audio_resources(&self) -> impl Iterator<Item = &Resource> {
|
||||
self.resources.iter()
|
||||
.filter(|r| r.protocol_info.contains("audio/"))
|
||||
}
|
||||
|
||||
/// Retourne la ressource principale (première disponible)
|
||||
pub fn primary_resource(&self) -> Option<&Resource> {
|
||||
self.resources.first()
|
||||
}
|
||||
|
||||
/// Itère sur les métadonnées sous forme de paires clé-valeur
|
||||
pub fn metadata(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
let mut pairs = Vec::new();
|
||||
|
||||
pairs.push(("title", self.title.as_str()));
|
||||
|
||||
if let Some(ref artist) = self.artist {
|
||||
pairs.push(("artist", artist.as_str()));
|
||||
}
|
||||
if let Some(ref album) = self.album {
|
||||
pairs.push(("album", album.as_str()));
|
||||
}
|
||||
if let Some(ref genre) = self.genre {
|
||||
pairs.push(("genre", genre.as_str()));
|
||||
}
|
||||
if let Some(ref date) = self.date {
|
||||
pairs.push(("date", date.as_str()));
|
||||
}
|
||||
if let Some(ref track) = self.original_track_number {
|
||||
pairs.push(("trackNumber", track.as_str()));
|
||||
}
|
||||
|
||||
for desc in &self.descriptions {
|
||||
if let Some(ref gain) = desc.track_gain {
|
||||
pairs.push(("replayGain", gain.as_str()));
|
||||
}
|
||||
if let Some(ref peak) = desc.track_peak {
|
||||
pairs.push(("replayPeak", peak.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
pairs.into_iter()
|
||||
}
|
||||
|
||||
fn write_markdown(&self, buf: &mut String, depth: usize) {
|
||||
let indent = " ".repeat(depth);
|
||||
|
||||
writeln!(buf, "{}- **Item**: {}", indent, self.title).unwrap();
|
||||
writeln!(buf, "{} - ID: `{}`", indent, self.id).unwrap();
|
||||
writeln!(buf, "{} - ParentID: `{}`", indent, self.parent_id).unwrap();
|
||||
writeln!(buf, "{} - Class: `{}`", indent, self.class).unwrap();
|
||||
|
||||
if let Some(ref creator) = self.creator {
|
||||
writeln!(buf, "{} - Creator: {}", indent, creator).unwrap();
|
||||
}
|
||||
if let Some(ref artist) = self.artist {
|
||||
writeln!(buf, "{} - Artist: {}", indent, artist).unwrap();
|
||||
}
|
||||
if let Some(ref album) = self.album {
|
||||
writeln!(buf, "{} - Album: {}", indent, album).unwrap();
|
||||
}
|
||||
if let Some(ref genre) = self.genre {
|
||||
writeln!(buf, "{} - Genre: {}", indent, genre).unwrap();
|
||||
}
|
||||
if let Some(ref art) = self.album_art {
|
||||
writeln!(buf, "{} - Album Art: ", indent, art).unwrap();
|
||||
}
|
||||
if let Some(ref date) = self.date {
|
||||
writeln!(buf, "{} - Date: {}", indent, date).unwrap();
|
||||
}
|
||||
if let Some(ref track) = self.original_track_number {
|
||||
writeln!(buf, "{} - Track: {}", indent, track).unwrap();
|
||||
}
|
||||
|
||||
if !self.resources.is_empty() {
|
||||
writeln!(buf, "{} - Resources:", indent).unwrap();
|
||||
for res in &self.resources {
|
||||
writeln!(buf, "{} - URL: {}", indent, res.url).unwrap();
|
||||
writeln!(buf, "{} - Protocol: `{}`", indent, res.protocol_info).unwrap();
|
||||
if let Some(ref dur) = res.duration {
|
||||
writeln!(buf, "{} - Duration: `{}`", indent, dur).unwrap();
|
||||
}
|
||||
if let Some(ref bits) = res.bits_per_sample {
|
||||
writeln!(buf, "{} - BitsPerSample: `{}`", indent, bits).unwrap();
|
||||
}
|
||||
if let Some(ref freq) = res.sample_frequency {
|
||||
writeln!(buf, "{} - SampleFrequency: `{}`", indent, freq).unwrap();
|
||||
}
|
||||
if let Some(ref channels) = res.nr_audio_channels {
|
||||
writeln!(buf, "{} - Channels: `{}`", indent, channels).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.descriptions.is_empty() {
|
||||
writeln!(buf, "{} - Descriptions:", indent).unwrap();
|
||||
for desc in &self.descriptions {
|
||||
if let Some(ref ns) = desc.namespace {
|
||||
writeln!(buf, "{} - Namespace: `{}`", indent, ns).unwrap();
|
||||
}
|
||||
if let Some(ref gain) = desc.track_gain {
|
||||
writeln!(buf, "{} - Track Gain: `{}`", indent, gain).unwrap();
|
||||
}
|
||||
if let Some(ref peak) = desc.track_peak {
|
||||
writeln!(buf, "{} - Track Peak: `{}`", indent, peak).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buf.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Itérateurs personnalisés =============
|
||||
|
||||
struct AllContainersIter<'a> {
|
||||
stack: Vec<&'a Container>,
|
||||
}
|
||||
|
||||
impl<'a> AllContainersIter<'a> {
|
||||
fn new(containers: &'a [Container]) -> Self {
|
||||
Self {
|
||||
stack: containers.iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for AllContainersIter<'a> {
|
||||
type Item = &'a Container;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.stack.pop().map(|container| {
|
||||
// Ajouter les enfants à la pile
|
||||
self.stack.extend(container.containers.iter());
|
||||
container
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct AllItemsIter<'a> {
|
||||
containers: Vec<&'a Container>,
|
||||
current_items: std::slice::Iter<'a, Item>,
|
||||
}
|
||||
|
||||
impl<'a> AllItemsIter<'a> {
|
||||
fn new(containers: &'a [Container], items: &'a [Item]) -> Self {
|
||||
Self {
|
||||
containers: containers.iter().collect(),
|
||||
current_items: items.iter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for AllItemsIter<'a> {
|
||||
type Item = &'a Item;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
if let Some(item) = self.current_items.next() {
|
||||
return Some(item);
|
||||
}
|
||||
|
||||
let container = self.containers.pop()?;
|
||||
self.containers.extend(container.containers.iter());
|
||||
self.current_items = container.items.iter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_didl() {
|
||||
let xml = r#"
|
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||
<item id="1" parentID="0">
|
||||
<dc:title>Test Song</dc:title>
|
||||
<upnp:class>object.item.audioItem.musicTrack</upnp:class>
|
||||
<res protocolInfo="http-get:*:audio/mpeg:*">http://example.com/song.mp3</res>
|
||||
</item>
|
||||
</DIDL-Lite>
|
||||
"#;
|
||||
|
||||
let didl = DIDLLite::parse(xml).unwrap();
|
||||
assert_eq!(didl.items.len(), 1);
|
||||
assert_eq!(didl.items[0].title, "Test Song");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_without_namespaces() {
|
||||
// Teste un XML sans namespaces explicites (devices UPnP laxistes)
|
||||
let xml = r#"
|
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
|
||||
<item id="1" parentID="0">
|
||||
<title>Test Song</title>
|
||||
<class>object.item.audioItem.musicTrack</class>
|
||||
<res protocolInfo="http-get:*:audio/mpeg:*">http://example.com/song.mp3</res>
|
||||
</item>
|
||||
</DIDL-Lite>
|
||||
"#;
|
||||
|
||||
let didl = DIDLLite::parse(xml).unwrap();
|
||||
assert_eq!(didl.items.len(), 1);
|
||||
assert_eq!(didl.items[0].title, "Test Song");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generic_parser() {
|
||||
let xml = r#"
|
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||
</DIDL-Lite>
|
||||
"#;
|
||||
|
||||
// Utiliser le parser générique
|
||||
let metadata: DidlMetadata = parse_metadata(xml).unwrap();
|
||||
|
||||
assert_eq!(metadata.format, "DIDL-Lite");
|
||||
assert!(metadata.parsed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_map() {
|
||||
let xml = r#"
|
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||
</DIDL-Lite>
|
||||
"#;
|
||||
|
||||
let metadata: DidlMetadata = parse_metadata(xml).unwrap();
|
||||
|
||||
// Transformer les données
|
||||
let item_count = metadata.map(|didl| didl.items.len());
|
||||
|
||||
assert_eq!(item_count.format, "DIDL-Lite");
|
||||
assert_eq!(item_count.data, 0);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,37 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.42"
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmodidl = { path = "../pmodidl"}
|
||||
|
||||
url = "2.5.7"
|
||||
uuid = "1.18.1"
|
||||
hex = "0.4.3"
|
||||
base64 = "0.22.1"
|
||||
thiserror = "2.0.16"
|
||||
xmltree = "0.11.0"
|
||||
xmltree = "0.11.0"
|
||||
get_if_addrs = "0.5.3"
|
||||
axum = "0.8.4"
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
|
||||
tokio-stream = "0.1"
|
||||
futures-util = "0.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
log = "0.4.28"
|
||||
once_cell = "1.20"
|
||||
parking_lot = "0.12"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3.6"
|
||||
axum-server = "0.7.2"
|
||||
axum-embed = "0.1.0"
|
||||
rust-embed = "8.7.2"
|
||||
anyhow = "1.0"
|
||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
||||
validator = { version = "0.20.0", features = ["derive"] }
|
||||
bevy_reflect = "0.17.1"
|
||||
bevy_reflect_derive = "0.17.1"
|
||||
reqwest = "0.12.23"
|
||||
|
||||
72
pmoupnp/errors.rs
Normal file
72
pmoupnp/errors.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use thiserror::Error;
|
||||
|
||||
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum StateVariableError {
|
||||
#[error("Conversion error: {0}")]
|
||||
ConversionError(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
#[error("Range error: {0}")]
|
||||
RangeError(String),
|
||||
|
||||
#[error("Type error: {0}")]
|
||||
TypeError(String),
|
||||
|
||||
#[error("Parse error: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Event condition error: {0}")]
|
||||
EventConditionError(String),
|
||||
|
||||
#[error("Arithmetic error: {0}")]
|
||||
ArithmeticError(String),
|
||||
|
||||
#[error("Unknown error: {0}")]
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<std::num::TryFromIntError> for StateVariableError {
|
||||
fn from(err: std::num::TryFromIntError) -> Self {
|
||||
StateVariableError::ConversionError(format!("Integer conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::str::ParseBoolError> for StateVariableError {
|
||||
fn from(err: std::str::ParseBoolError) -> Self {
|
||||
StateVariableError::ConversionError(format!("Boolean conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Error> for StateVariableError {
|
||||
fn from(err: uuid::Error) -> Self {
|
||||
StateVariableError::ConversionError(format!("UUID conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chrono::ParseError> for StateVariableError {
|
||||
fn from(err: chrono::ParseError) -> Self {
|
||||
StateVariableError::ConversionError(format!("Time conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for StateVariableError {
|
||||
fn from(err: url::ParseError) -> Self {
|
||||
StateVariableError::ConversionError(format!("URI conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for StateVariableError {
|
||||
fn from(err: base64::DecodeError) -> Self {
|
||||
StateVariableError::ConversionError(format!("Base64 conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<hex::FromHexError> for StateVariableError {
|
||||
fn from(err: hex::FromHexError) -> Self {
|
||||
StateVariableError::ConversionError(format!("Hex conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
136
pmoupnp/src/actions/action_instance.rs
Normal file
136
pmoupnp/src/actions/action_instance.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::actions::Action;
|
||||
use crate::actions::Argument;
|
||||
use crate::actions::ArgumentSet;
|
||||
use crate::actions::ArgInstanceSet;
|
||||
use crate::actions::ActionInstance;
|
||||
use crate::UpnpInstance;
|
||||
use crate::UpnpObject;
|
||||
use crate::UpnpTyped;
|
||||
use crate::UpnpTypedInstance;
|
||||
use crate::UpnpObjectType;
|
||||
|
||||
impl UpnpObject for ActionInstance {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("action");
|
||||
|
||||
// <name>
|
||||
let mut name_elem = Element::new("name");
|
||||
name_elem.children.push(XMLNode::Text(self.get_name().clone()));
|
||||
elem.children.push(XMLNode::Element(name_elem));
|
||||
|
||||
// Utiliser le set d'instances d'arguments
|
||||
let args_container = self.arguments.to_xml_element();
|
||||
elem.children.push(XMLNode::Element(args_container));
|
||||
|
||||
elem
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpTyped for ActionInstance {
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType {
|
||||
return &self.object;
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpInstance for ActionInstance {
|
||||
|
||||
type Model = Action;
|
||||
|
||||
fn new(action: &Action) -> Self {
|
||||
// Créer les instances d'arguments
|
||||
let mut arguments = ArgInstanceSet::new();
|
||||
|
||||
for arg_model in action.arguments().all() {
|
||||
let arg_instance = Arc::new(crate::actions::ArgumentInstance::new(&*arg_model));
|
||||
if let Err(e) = arguments.insert(arg_instance) {
|
||||
tracing::error!("Failed to insert argument instance: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
object: UpnpObjectType {
|
||||
name: action.get_name().clone(),
|
||||
object_type: "ActionInstance".to_string(),
|
||||
},
|
||||
model: action.clone(),
|
||||
arguments, // ⬅️ Set d'instances, pas le modèle !
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
impl UpnpTypedInstance for ActionInstance {
|
||||
|
||||
fn get_model(&self) -> &Self::Model {
|
||||
&self.model
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionInstance {
|
||||
/// Retourne une instance d'argument par son nom.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - Nom de l'argument à rechercher
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Some(Arc<ArgumentInstance>)` si trouvé, `None` sinon.
|
||||
pub fn argument(&self, name: &str) -> Option<Arc<crate::actions::ArgumentInstance>> {
|
||||
self.arguments.get_by_name(name)
|
||||
}
|
||||
|
||||
/// Retourne le set d'instances d'arguments.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Référence vers le `ArgInstanceSet` contenant toutes les instances.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// for arg_instance in action_instance.arguments_set().all() {
|
||||
/// println!("Argument: {}", arg_instance.get_name());
|
||||
/// if let Some(var) = arg_instance.get_variable_instance() {
|
||||
/// println!(" Variable: {} = {}", var.get_name(), var.value());
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn arguments_set(&self) -> &ArgInstanceSet {
|
||||
&self.arguments // ⬅️ Retourne les INSTANCES, pas les modèles !
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::Action;
|
||||
use crate::UpnpInstance;
|
||||
|
||||
#[test]
|
||||
fn test_action_instance_creation() {
|
||||
let action = Action::new("Play".to_string());
|
||||
let instance = ActionInstance::new(&action);
|
||||
|
||||
assert_eq!(instance.get_name(), "Play");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_instance_has_argument_instances() {
|
||||
let action = Action::new("Play".to_string());
|
||||
let instance = ActionInstance::new(&action);
|
||||
|
||||
// Vérifier que arguments_set() retourne bien des instances
|
||||
assert!(instance.arguments_set().all().iter().all(|arg| {
|
||||
// Chaque argument doit être une ArgumentInstance
|
||||
arg.get_model(); // Cette méthode existe seulement sur les instances
|
||||
true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
21
pmoupnp/src/actions/action_instance_set.rs
Normal file
21
pmoupnp/src/actions/action_instance_set.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::{
|
||||
UpnpObject,
|
||||
actions::{ActionInstanceSet},
|
||||
};
|
||||
|
||||
use xmltree::{Element,XMLNode};
|
||||
|
||||
impl UpnpObject for ActionInstanceSet {
|
||||
// Méthode pour convertir en XML (à implémenter avec une librairie XML)
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("actionList");
|
||||
|
||||
for action in self.all() {
|
||||
let action_elem = action.to_xml_element(); // retourne un <action> complet
|
||||
elem.children.push(XMLNode::Element(action_elem));
|
||||
}
|
||||
|
||||
elem
|
||||
}
|
||||
}
|
||||
|
||||
62
pmoupnp/src/actions/action_methods.rs
Normal file
62
pmoupnp/src/actions/action_methods.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::UpnpModel;
|
||||
use crate::UpnpObject;
|
||||
use crate::UpnpObjectSetError;
|
||||
use crate::UpnpObjectType;
|
||||
use crate::UpnpTyped;
|
||||
use crate::actions::Action;
|
||||
use crate::actions::ActionInstance;
|
||||
use crate::actions::Argument;
|
||||
use crate::actions::ArgumentSet;
|
||||
|
||||
impl UpnpObject for Action {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut action_elem = Element::new("action");
|
||||
|
||||
// <name>
|
||||
let mut name_elem = Element::new("name");
|
||||
name_elem
|
||||
.children
|
||||
.push(XMLNode::Text(self.get_name().clone()));
|
||||
action_elem.children.push(XMLNode::Element(name_elem));
|
||||
|
||||
// <argumentList>
|
||||
let args_elem = self.arguments.to_xml_element();
|
||||
action_elem.children.push(XMLNode::Element(args_elem));
|
||||
|
||||
action_elem
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpModel for Action {
|
||||
type Instance = ActionInstance;
|
||||
}
|
||||
|
||||
impl UpnpTyped for Action {
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType {
|
||||
return &self.object;
|
||||
}
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn new(name: String) -> Action {
|
||||
Self {
|
||||
object: UpnpObjectType {
|
||||
name,
|
||||
object_type: "Action".to_string(),
|
||||
},
|
||||
arguments: ArgumentSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_argument(&mut self, arg: Arc<Argument>) -> Result<(), UpnpObjectSetError> {
|
||||
self.arguments.insert(arg)
|
||||
}
|
||||
|
||||
pub fn arguments(&self) -> &ArgumentSet {
|
||||
&self.arguments
|
||||
}
|
||||
}
|
||||
23
pmoupnp/src/actions/action_set_methods.rs
Normal file
23
pmoupnp/src/actions/action_set_methods.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::actions::{ActionInstanceSet, ActionSet};
|
||||
use crate::{UpnpModel, UpnpObject};
|
||||
|
||||
impl UpnpObject for ActionSet {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("actionList");
|
||||
|
||||
for action in self.all() {
|
||||
let action_elem = action.to_xml_element(); // retourne un <action> complet
|
||||
elem.children.push(XMLNode::Element(action_elem));
|
||||
}
|
||||
|
||||
elem
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
impl UpnpModel for ActionSet {
|
||||
type Instance = ActionInstanceSet;
|
||||
}
|
||||
31
pmoupnp/src/actions/arg_inst_set_methods.rs
Normal file
31
pmoupnp/src/actions/arg_inst_set_methods.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use std::sync::RwLock;
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::{actions::{ArgInstanceSet, ArgumentSet}, UpnpObject};
|
||||
|
||||
use crate::UpnpInstance;
|
||||
|
||||
impl UpnpObject for ArgInstanceSet {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("serviceStateTable");
|
||||
|
||||
for state_var in self.all() {
|
||||
let state_var_elem = state_var.to_xml_element(); // retourne un <stateVariable> complet
|
||||
elem.children.push(XMLNode::Element(state_var_elem));
|
||||
}
|
||||
|
||||
elem
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpInstance for ArgInstanceSet {
|
||||
type Model = ArgumentSet;
|
||||
|
||||
fn new(_: &ArgumentSet) -> Self {
|
||||
Self { objects: RwLock::new(HashMap::new()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
256
pmoupnp/src/actions/arg_instance_methods.rs
Normal file
256
pmoupnp/src/actions/arg_instance_methods.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
use std::{collections::HashMap, sync::{Arc, RwLock}};
|
||||
|
||||
use xmltree::Element;
|
||||
|
||||
use crate::{actions::{ActionInstanceSet, ActionSet, Argument, ArgumentInstance}, state_variables::StateVarInstance, UpnpInstance, UpnpObject, UpnpObjectType, UpnpTyped, UpnpTypedInstance};
|
||||
|
||||
|
||||
impl UpnpObject for ArgumentInstance {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
self.get_model().to_xml_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpTyped for ArgumentInstance {
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType {
|
||||
return &self.object;
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémentation de [`UpnpTypedInstance`] pour [`ArgumentInstance`].
|
||||
///
|
||||
/// Cette implémentation permet d'accéder au modèle [`Argument`] depuis l'instance
|
||||
/// via la méthode [`get_model()`](UpnpTypedInstance::get_model).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmoupnp::UpnpTypedInstance;
|
||||
///
|
||||
/// let arg_instance = ArgumentInstance::new(&arg_model);
|
||||
///
|
||||
/// // Accéder au modèle
|
||||
/// let model = arg_instance.get_model();
|
||||
/// println!("Direction: in={}, out={}", model.is_in(), model.is_out());
|
||||
/// println!("Related variable: {}", model.state_variable().get_name());
|
||||
/// ```
|
||||
impl UpnpTypedInstance for ArgumentInstance {
|
||||
/// Retourne une référence vers le modèle [`Argument`].
|
||||
///
|
||||
/// Permet d'accéder aux métadonnées statiques définies dans le modèle :
|
||||
/// - Direction de l'argument (in/out)
|
||||
/// - Variable d'état associée
|
||||
/// - Nom et type
|
||||
fn get_model(&self) -> &Self::Model {
|
||||
&self.model
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Implémentation de [`UpnpInstance`] pour [`ArgumentInstance`].
|
||||
///
|
||||
/// Cette implémentation fournit le constructeur standard qui crée une instance
|
||||
/// **non liée** d'un argument. La liaison à une [`StateVarInstance`] doit être
|
||||
/// effectuée séparément via [`bind_variable`](ArgumentInstance::bind_variable).
|
||||
///
|
||||
/// # Processus de construction en deux phases
|
||||
///
|
||||
/// ```text
|
||||
/// Phase 1 (new) Phase 2 (bind_variable)
|
||||
/// ┌─────────────────┐ ┌──────────────────────┐
|
||||
/// │ ArgumentInstance│ │ StateVarInstance │
|
||||
/// │ │ │ │
|
||||
/// │ model: Arc<...> │────>│ Liaison établie │
|
||||
/// │ variable: None │ │ variable: Some(...) │
|
||||
/// └─────────────────┘ └──────────────────────┘
|
||||
/// ↓ ↓
|
||||
/// Création bind_variable(&var)
|
||||
/// ```
|
||||
///
|
||||
/// # Pourquoi deux phases ?
|
||||
///
|
||||
/// 1. **Ordre de création** : Les modèles (`Argument`) existent avant les instances
|
||||
/// 2. **Validation différée** : Les dépendances sont vérifiées après instanciation
|
||||
/// 3. **Découplage** : Permet de créer des arguments même si les variables n'existent pas encore
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmoupnp::actions::{Argument, ArgumentInstance};
|
||||
/// use pmoupnp::UpnpInstance;
|
||||
///
|
||||
/// let arg_model = Argument::new_in("InstanceID".to_string(), instance_id_var);
|
||||
///
|
||||
/// // Création de l'instance - Phase 1
|
||||
/// let arg_instance = ArgumentInstance::new(&arg_model);
|
||||
///
|
||||
/// // À ce stade, l'instance existe mais n'est pas encore liée
|
||||
/// assert_eq!(arg_instance.get_name(), "InstanceID");
|
||||
/// assert!(arg_instance.get_variable_instance().is_none());
|
||||
///
|
||||
/// // La liaison se fera plus tard via bind_variable()
|
||||
/// ```
|
||||
impl UpnpInstance for ArgumentInstance {
|
||||
type Model = Argument;
|
||||
|
||||
/// Crée une nouvelle instance d'argument depuis son modèle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `from` - Référence vers le modèle [`Argument`] définissant cet argument
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Une nouvelle `ArgumentInstance` avec :
|
||||
/// - Nom copié depuis le modèle
|
||||
/// - Référence vers le modèle (clone)
|
||||
/// - `variable_instance` initialisé à `None` (liaison non établie)
|
||||
///
|
||||
/// # État initial
|
||||
///
|
||||
/// L'instance créée n'est **pas encore liée** à une variable d'état.
|
||||
/// Pour établir la liaison, appelez [`bind_variable`](ArgumentInstance::bind_variable).
|
||||
///
|
||||
/// # Thread-safety
|
||||
///
|
||||
/// L'instance retournée est thread-safe et peut être partagée via `Arc`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmoupnp::UpnpInstance;
|
||||
///
|
||||
/// // Création depuis un modèle
|
||||
/// let instance = ArgumentInstance::new(&arg_model);
|
||||
///
|
||||
/// // L'instance hérite des propriétés du modèle
|
||||
/// assert_eq!(instance.get_name(), arg_model.get_name());
|
||||
/// assert_eq!(instance.is_in(), arg_model.is_in());
|
||||
///
|
||||
/// // Mais n'a pas encore de valeur runtime
|
||||
/// assert!(instance.get_variable_instance().is_none());
|
||||
/// ```
|
||||
fn new(from: &Argument) -> Self {
|
||||
Self {
|
||||
// Copie des métadonnées depuis le modèle
|
||||
object: UpnpObjectType {
|
||||
name: from.get_name().clone(),
|
||||
object_type: "ArgumentInstance".to_string(),
|
||||
},
|
||||
|
||||
// Clone du modèle pour référence future
|
||||
model: from.clone(),
|
||||
|
||||
// Initialisation à None - sera lié plus tard via bind_variable()
|
||||
// Arc<RwLock<...>> permet la modification thread-safe post-construction
|
||||
variable_instance: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Méthodes de liaison et d'accès
|
||||
// ============================================================================
|
||||
|
||||
impl ArgumentInstance {
|
||||
/// Lie cet argument à une instance de variable d'état.
|
||||
///
|
||||
/// Cette méthode établit la connexion entre l'argument et sa variable d'état,
|
||||
/// permettant l'accès aux valeurs runtime lors de l'exécution d'actions.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `var_instance` - Instance de la variable d'état à lier
|
||||
///
|
||||
/// # Thread-safety
|
||||
///
|
||||
/// Cette méthode acquiert un **write lock** sur `variable_instance` et peut
|
||||
/// bloquer si d'autres threads lisent actuellement la valeur.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panique si le lock est empoisonné (poisoned), ce qui ne devrait jamais
|
||||
/// arriver dans un usage normal.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// let arg_instance = ArgumentInstance::new(&arg_model);
|
||||
/// let var_instance = Arc::new(StateVarInstance::new(&state_var));
|
||||
///
|
||||
/// // Établir la liaison
|
||||
/// arg_instance.bind_variable(var_instance.clone());
|
||||
///
|
||||
/// // Vérifier que la liaison est établie
|
||||
/// assert!(arg_instance.get_variable_instance().is_some());
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Cette méthode peut être appelée plusieurs fois pour changer la variable liée,
|
||||
/// bien que ce ne soit généralement pas recommandé dans un usage normal.
|
||||
pub fn bind_variable(&self, var_instance: Arc<StateVarInstance>) {
|
||||
let mut var = self.variable_instance.write().unwrap();
|
||||
*var = Some(var_instance);
|
||||
}
|
||||
|
||||
/// Retourne l'instance de variable d'état liée, si elle existe.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Some(Arc<StateVarInstance>)` si une variable est liée
|
||||
/// - `None` si aucune liaison n'a été établie via [`bind_variable`](Self::bind_variable)
|
||||
///
|
||||
/// # Thread-safety
|
||||
///
|
||||
/// Cette méthode acquiert un **read lock** sur `variable_instance`.
|
||||
/// Plusieurs threads peuvent lire simultanément sans blocage.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panique si le lock est empoisonné (poisoned).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Vérifier si la liaison existe
|
||||
/// if let Some(var) = arg_instance.get_variable_instance() {
|
||||
/// println!("Variable liée : {}", var.get_name());
|
||||
/// println!("Valeur actuelle : {}", var.value());
|
||||
/// } else {
|
||||
/// println!("Aucune variable liée");
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Usage dans l'exécution d'actions
|
||||
///
|
||||
/// ```ignore
|
||||
/// async fn execute_action(action: &ActionInstance) -> Result<(), ActionError> {
|
||||
/// for arg in action.arguments_set().all() {
|
||||
/// if let Some(var) = arg.get_variable_instance() {
|
||||
/// // Utiliser var.value() pour lire/écrire
|
||||
/// println!("Paramètre {} = {}", arg.get_name(), var.value());
|
||||
/// } else {
|
||||
/// return Err(ActionError::UnboundArgument(arg.get_name().to_string()));
|
||||
/// }
|
||||
/// }
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub fn get_variable_instance(&self) -> Option<Arc<StateVarInstance>> {
|
||||
self.variable_instance.read().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl UpnpInstance for ActionInstanceSet {
|
||||
type Model = ActionSet;
|
||||
|
||||
fn new(_: &ActionSet) -> Self {
|
||||
Self {
|
||||
objects: RwLock::new(HashMap::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
29
pmoupnp/src/actions/arg_set_methods.rs
Normal file
29
pmoupnp/src/actions/arg_set_methods.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crate::actions::ArgInstanceSet;
|
||||
use crate::UpnpModel;
|
||||
use crate::{
|
||||
UpnpObject,
|
||||
actions::{ArgumentSet},
|
||||
};
|
||||
use xmltree::Element;
|
||||
|
||||
impl UpnpObject for ArgumentSet {
|
||||
// Méthode pour convertir en XML (à implémenter avec une librairie XML)
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("argumentList");
|
||||
|
||||
for arg in self.all() {
|
||||
let arg_elem = arg.to_xml_element(); // toujours un <argumentList> contenant 1 ou 2 <argument>
|
||||
|
||||
// Pour InOut, on ajoute tous les enfants du <argumentList> généré
|
||||
for child in arg_elem.children {
|
||||
elem.children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
elem
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpModel for ArgumentSet {
|
||||
type Instance = ArgInstanceSet;
|
||||
}
|
||||
116
pmoupnp/src/actions/argument_methods.rs
Normal file
116
pmoupnp/src/actions/argument_methods.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::{
|
||||
actions::{Argument, ArgumentInstance}, state_variables::StateVariable, UpnpModel, UpnpObject, UpnpObjectType, UpnpTyped
|
||||
};
|
||||
|
||||
impl UpnpTyped for Argument {
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType {
|
||||
&self.object
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpObject for Argument {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut parent = Element::new("argumentList");
|
||||
|
||||
if self.is_in() && self.is_out() {
|
||||
// InOut → deux arguments
|
||||
parent.children.push(XMLNode::Element(make_argument_elem(
|
||||
self.get_name(),
|
||||
"in",
|
||||
self.state_variable().get_name(),
|
||||
)));
|
||||
parent.children.push(XMLNode::Element(make_argument_elem(
|
||||
self.get_name(),
|
||||
"out",
|
||||
self.state_variable().get_name(),
|
||||
)));
|
||||
} else {
|
||||
// Cas simple
|
||||
let direction = if self.is_in() { "in" } else { "out" };
|
||||
parent.children.push(XMLNode::Element(make_argument_elem(
|
||||
self.get_name(),
|
||||
direction,
|
||||
self.state_variable().get_name(),
|
||||
)));
|
||||
}
|
||||
|
||||
parent
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpModel for Argument {
|
||||
type Instance = ArgumentInstance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl Argument {
|
||||
fn new(name: String, state_variable: Arc<StateVariable>) -> Self {
|
||||
Self {
|
||||
object: UpnpObjectType {
|
||||
name,
|
||||
object_type: "Argument".to_string(),
|
||||
},
|
||||
state_variable,
|
||||
is_in: false,
|
||||
is_out: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_in(name: String, state_variable: Arc<StateVariable>) -> Self {
|
||||
let mut arg = Self::new(name, state_variable);
|
||||
arg.is_in = true;
|
||||
arg
|
||||
}
|
||||
|
||||
pub fn new_out(name: String, state_variable: Arc<StateVariable>) -> Self {
|
||||
let mut arg = Self::new(name, state_variable);
|
||||
arg.is_out = true;
|
||||
arg
|
||||
}
|
||||
|
||||
pub fn new_in_out(name: String, state_variable: Arc<StateVariable>) -> Self {
|
||||
let mut arg = Self::new(name, state_variable);
|
||||
arg.is_in = true;
|
||||
arg.is_out = true;
|
||||
arg
|
||||
}
|
||||
|
||||
pub fn state_variable(&self) -> &StateVariable {
|
||||
&self.state_variable
|
||||
}
|
||||
|
||||
pub fn is_in(&self) -> bool {
|
||||
self.is_in
|
||||
}
|
||||
|
||||
pub fn is_out(&self) -> bool {
|
||||
self.is_out
|
||||
}
|
||||
}
|
||||
|
||||
/// Fabrique un <argument> complet avec ses sous-éléments
|
||||
fn make_argument_elem(name: &str, direction: &str, state_var_name: &str) -> Element {
|
||||
let mut arg = Element::new("argument");
|
||||
|
||||
let mut name_elem = Element::new("name");
|
||||
name_elem.children.push(XMLNode::Text(name.to_string()));
|
||||
|
||||
let mut dir_elem = Element::new("direction");
|
||||
dir_elem.children.push(XMLNode::Text(direction.to_string()));
|
||||
|
||||
let mut rel_elem = Element::new("relatedStateVariable");
|
||||
rel_elem
|
||||
.children
|
||||
.push(XMLNode::Text(state_var_name.to_string()));
|
||||
|
||||
arg.children.push(XMLNode::Element(name_elem));
|
||||
arg.children.push(XMLNode::Element(dir_elem));
|
||||
arg.children.push(XMLNode::Element(rel_elem));
|
||||
|
||||
arg
|
||||
}
|
||||
37
pmoupnp/src/actions/errors.rs
Normal file
37
pmoupnp/src/actions/errors.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ActionError {
|
||||
#[error("Action error: {0}")]
|
||||
GeneralError(String),
|
||||
|
||||
#[error("Argument error: {0}")]
|
||||
ArgumentError(String),
|
||||
|
||||
#[error("Set operation error: {0}")]
|
||||
SetError(String),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ActionError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
ActionError::GeneralError(format!("IO error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ArgumentError {
|
||||
#[error("Argument error: {0}")]
|
||||
GeneralError(String),
|
||||
|
||||
#[error("Argument error: {0}")]
|
||||
ArgumentError(String),
|
||||
|
||||
#[error("Set operation error: {0}")]
|
||||
SetError(String),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ArgumentError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
ArgumentError::GeneralError(format!("IO error: {}", err))
|
||||
}
|
||||
}
|
||||
280
pmoupnp/src/actions/macros.rs
Normal file
280
pmoupnp/src/actions/macros.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
/// Macro pour définir facilement une action UPnP.
|
||||
///
|
||||
/// Cette macro simplifie la création d'actions UPnP statiques en générant
|
||||
/// automatiquement le code nécessaire pour initialiser une action avec ses arguments.
|
||||
///
|
||||
/// # Syntaxe
|
||||
///
|
||||
/// ## Action avec arguments
|
||||
///
|
||||
/// ```ignore
|
||||
/// define_action! {
|
||||
/// pub static ACTION_NAME = "ActionName" {
|
||||
/// in "ParamName" => VARIABLE_REF,
|
||||
/// out "ResultParam" => RESULT_VAR,
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## Action sans arguments
|
||||
///
|
||||
/// ```ignore
|
||||
/// define_action! {
|
||||
/// pub static ACTION_NAME = "ActionName"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - `ACTION_NAME` : Nom de la constante statique Rust
|
||||
/// - `"ActionName"` : Nom de l'action UPnP (chaîne littérale)
|
||||
/// - `in` ou `out` : Direction de l'argument (entrée ou sortie)
|
||||
/// - `"ParamName"` : Nom du paramètre UPnP (chaîne littérale)
|
||||
/// - `VARIABLE_REF` : Référence vers une `Lazy<Arc<StateVariable>>`
|
||||
///
|
||||
/// # Type de retour
|
||||
///
|
||||
/// La macro génère une `Lazy<Arc<Action>>` qui sera initialisée lors du premier accès.
|
||||
///
|
||||
/// # Prérequis
|
||||
///
|
||||
/// Les variables d'état référencées doivent être définies comme :
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub static MY_VAR: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::UI4, "MyVar".to_string()))
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use once_cell::sync::Lazy;
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// // Définir les variables d'état
|
||||
/// pub static INSTANCE_ID: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::UI4, "InstanceID".to_string()))
|
||||
/// });
|
||||
///
|
||||
/// pub static TRANSPORT_URI: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::String, "TransportURI".to_string()))
|
||||
/// });
|
||||
///
|
||||
/// // Définir une action avec arguments
|
||||
/// define_action! {
|
||||
/// pub static PLAY = "Play" {
|
||||
/// in "InstanceID" => INSTANCE_ID,
|
||||
/// in "Speed" => TRANSPORT_SPEED,
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // Action sans arguments
|
||||
/// define_action! {
|
||||
/// pub static PAUSE = "Pause"
|
||||
/// }
|
||||
///
|
||||
/// // Utilisation
|
||||
/// fn main() {
|
||||
/// let play_action = &*PLAY; // Déréférence la Lazy<Arc<Action>>
|
||||
/// println!("Action: {}", play_action.get_name());
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Notes d'implémentation
|
||||
///
|
||||
/// - Les `Arc<StateVariable>` sont clonés (shallow copy du pointeur)
|
||||
/// - Chaque `Argument` est wrappé dans un `Arc`
|
||||
/// - L'`Action` finale est wrappée dans un `Arc`
|
||||
/// - Initialisation paresseuse via `Lazy` (thread-safe)
|
||||
#[macro_export]
|
||||
macro_rules! define_action {
|
||||
// Variante sans arguments
|
||||
(pub static $name:ident = $action_name:literal) => {
|
||||
pub static $name: once_cell::sync::Lazy<std::sync::Arc<$crate::actions::Action>> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
std::sync::Arc::new($crate::actions::Action::new($action_name.to_string()))
|
||||
});
|
||||
};
|
||||
|
||||
// Variante avec arguments
|
||||
(pub static $name:ident = $action_name:literal {
|
||||
$(
|
||||
$direction:ident $arg_name:literal => $var_ref:expr
|
||||
),* $(,)?
|
||||
}) => {
|
||||
pub static $name: once_cell::sync::Lazy<std::sync::Arc<$crate::actions::Action>> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
let mut ac = $crate::actions::Action::new($action_name.to_string());
|
||||
|
||||
$(
|
||||
ac.add_argument(
|
||||
define_action!(@arg $direction $arg_name, $var_ref)
|
||||
);
|
||||
)*
|
||||
|
||||
std::sync::Arc::new(ac)
|
||||
});
|
||||
};
|
||||
|
||||
// Helper interne pour créer un argument d'entrée
|
||||
(@arg in $name:literal, $var:expr) => {
|
||||
std::sync::Arc::new(
|
||||
$crate::actions::Argument::new_in(
|
||||
$name.to_string(),
|
||||
std::sync::Arc::clone(&$var)
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
// Helper interne pour créer un argument de sortie
|
||||
(@arg out $name:literal, $var:expr) => {
|
||||
std::sync::Arc::new(
|
||||
$crate::actions::Argument::new_out(
|
||||
$name.to_string(),
|
||||
std::sync::Arc::clone(&$var)
|
||||
)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Macro pour définir plusieurs actions UPnP en une seule déclaration.
|
||||
///
|
||||
/// Cette macro permet de regrouper la définition de plusieurs actions pour
|
||||
/// améliorer la lisibilité et réduire la répétition de code.
|
||||
///
|
||||
/// # Syntaxe
|
||||
///
|
||||
/// ```ignore
|
||||
/// define_actions! {
|
||||
/// ACTION1 = "Action1" {
|
||||
/// in "Param1" => VAR1,
|
||||
/// out "Result1" => VAR2,
|
||||
/// }
|
||||
///
|
||||
/// ACTION2 = "Action2" {
|
||||
/// in "Param1" => VAR1,
|
||||
/// }
|
||||
///
|
||||
/// ACTION3 = "Action3"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// Chaque action suit la même syntaxe que [`define_action!`], mais sans
|
||||
/// le mot-clé `pub static`.
|
||||
///
|
||||
/// # Type de retour
|
||||
///
|
||||
/// Génère une `Lazy<Arc<Action>>` pour chaque action définie.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use once_cell::sync::Lazy;
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// // Variables d'état
|
||||
/// pub static INSTANCE_ID: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::UI4, "InstanceID".to_string()))
|
||||
/// });
|
||||
///
|
||||
/// pub static TRANSPORT_URI: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::String, "TransportURI".to_string()))
|
||||
/// });
|
||||
///
|
||||
/// pub static URI_METADATA: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::String, "URIMetaData".to_string()))
|
||||
/// });
|
||||
///
|
||||
/// // Définir plusieurs actions ensemble
|
||||
/// define_actions! {
|
||||
/// PLAY = "Play" {
|
||||
/// in "InstanceID" => INSTANCE_ID,
|
||||
/// }
|
||||
///
|
||||
/// STOP = "Stop" {
|
||||
/// in "InstanceID" => INSTANCE_ID,
|
||||
/// }
|
||||
///
|
||||
/// PAUSE = "Pause" {
|
||||
/// in "InstanceID" => INSTANCE_ID,
|
||||
/// }
|
||||
///
|
||||
/// SET_AV_TRANSPORT_URI = "SetAVTransportURI" {
|
||||
/// in "InstanceID" => INSTANCE_ID,
|
||||
/// in "CurrentURI" => TRANSPORT_URI,
|
||||
/// in "CurrentURIMetaData" => URI_METADATA,
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // Utilisation
|
||||
/// fn setup_transport_service() {
|
||||
/// let actions = vec![&*PLAY, &*STOP, &*PAUSE, &*SET_AV_TRANSPORT_URI];
|
||||
/// for action in actions {
|
||||
/// println!("Action: {}", action.get_name());
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Avantages
|
||||
///
|
||||
/// - Regroupement logique des actions d'un service
|
||||
/// - Réduction de la répétition de `pub static` et `define_action!`
|
||||
/// - Meilleure lisibilité pour les services avec nombreuses actions
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// - Toutes les actions définies sont publiques (`pub`)
|
||||
/// - Chaque action est indépendante et peut être utilisée séparément
|
||||
/// - La macro se développe en plusieurs appels à [`define_action!`]
|
||||
#[macro_export]
|
||||
macro_rules! define_actions {
|
||||
// Variante avec arguments pour chaque action
|
||||
(
|
||||
$(
|
||||
$name:ident = $action_name:literal {
|
||||
$(
|
||||
$direction:ident $arg_name:literal => $var_ref:expr
|
||||
),* $(,)?
|
||||
}
|
||||
)*
|
||||
) => {
|
||||
$(
|
||||
define_action! {
|
||||
pub static $name = $action_name {
|
||||
$($direction $arg_name => $var_ref),*
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
|
||||
// Variante mixte : actions avec et sans arguments
|
||||
(
|
||||
$(
|
||||
$name:ident = $action_name:literal $({
|
||||
$(
|
||||
$direction:ident $arg_name:literal => $var_ref:expr
|
||||
),* $(,)?
|
||||
})?
|
||||
)*
|
||||
) => {
|
||||
$(
|
||||
$(
|
||||
define_action! {
|
||||
pub static $name = $action_name {
|
||||
$($direction $arg_name => $var_ref),*
|
||||
}
|
||||
}
|
||||
)?
|
||||
$(
|
||||
// Cas sans accolades (action sans arguments)
|
||||
#[allow(unused)]
|
||||
define_action! {
|
||||
pub static $name = $action_name
|
||||
}
|
||||
)?
|
||||
)*
|
||||
};
|
||||
}
|
||||
116
pmoupnp/src/actions/mod.rs
Normal file
116
pmoupnp/src/actions/mod.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
mod errors;
|
||||
|
||||
mod action_instance;
|
||||
mod action_instance_set;
|
||||
mod action_methods;
|
||||
mod action_set_methods;
|
||||
mod arg_inst_set_methods;
|
||||
mod arg_instance_methods;
|
||||
mod arg_set_methods;
|
||||
mod argument_methods;
|
||||
|
||||
mod macros;
|
||||
|
||||
use crate::{
|
||||
UpnpObjectSet, UpnpObjectType,
|
||||
state_variables::{StateVarInstance, StateVariable},
|
||||
};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
pub use errors::ActionError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Action {
|
||||
object: UpnpObjectType,
|
||||
arguments: ArgumentSet,
|
||||
}
|
||||
|
||||
pub type ActionSet = UpnpObjectSet<Action>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActionInstance {
|
||||
object: UpnpObjectType,
|
||||
model: Action,
|
||||
arguments: ArgInstanceSet,
|
||||
}
|
||||
|
||||
pub type ActionInstanceSet = UpnpObjectSet<ActionInstance>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Argument {
|
||||
object: UpnpObjectType,
|
||||
state_variable: Arc<StateVariable>,
|
||||
is_in: bool,
|
||||
is_out: bool,
|
||||
}
|
||||
|
||||
pub type ArgumentSet = UpnpObjectSet<Argument>;
|
||||
|
||||
/// Instance d'un argument d'action UPnP.
|
||||
///
|
||||
/// Un `ArgumentInstance` représente un argument concret utilisé lors de l'exécution
|
||||
/// d'une action. Contrairement au modèle [`Argument`] qui définit la structure,
|
||||
/// l'instance maintient une liaison dynamique vers une [`StateVarInstance`] qui
|
||||
/// contient la valeur runtime.
|
||||
///
|
||||
/// # Cycle de vie
|
||||
///
|
||||
/// 1. **Création** : Instanciation via [`UpnpInstance::new`] avec `variable_instance = None`
|
||||
/// 2. **Liaison** : Association à une [`StateVarInstance`] via [`bind_variable`](Self::bind_variable)
|
||||
/// 3. **Utilisation** : Accès à la valeur runtime via [`get_variable_instance`](Self::get_variable_instance)
|
||||
///
|
||||
/// # Pourquoi `variable_instance` est optionnel ?
|
||||
///
|
||||
/// La liaison ne peut pas être faite dans le constructeur car :
|
||||
/// - Les `StateVarInstance` sont créées **après** les modèles
|
||||
/// - Les `ActionInstance` sont créées **avant** que toutes les variables soient disponibles
|
||||
/// - La validation des dépendances se fait en deux phases
|
||||
///
|
||||
/// # Thread-safety
|
||||
///
|
||||
/// Le champ `variable_instance` est protégé par un `RwLock` pour permettre :
|
||||
/// - La liaison après création (write lock)
|
||||
/// - L'accès concurrent en lecture (read lock)
|
||||
/// - L'utilisation dans un contexte multi-thread
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmoupnp::actions::{Argument, ArgumentInstance};
|
||||
/// use pmoupnp::state_variables::StateVarInstance;
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// // Phase 1 : Créer l'instance (sans liaison)
|
||||
/// let arg_model = Argument::new_in("Volume".to_string(), volume_var);
|
||||
/// let arg_instance = ArgumentInstance::new(&arg_model);
|
||||
/// assert!(arg_instance.get_variable_instance().is_none());
|
||||
///
|
||||
/// // Phase 2 : Lier à une variable d'état
|
||||
/// let var_instance = Arc::new(StateVarInstance::new(&volume_var));
|
||||
/// arg_instance.bind_variable(var_instance.clone());
|
||||
/// assert!(arg_instance.get_variable_instance().is_some());
|
||||
///
|
||||
/// // Phase 3 : Utiliser la valeur runtime
|
||||
/// if let Some(var) = arg_instance.get_variable_instance() {
|
||||
/// println!("Current value: {}", var.value());
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArgumentInstance {
|
||||
/// Métadonnées de l'objet UPnP
|
||||
object: UpnpObjectType,
|
||||
|
||||
/// Référence vers le modèle définissant la structure
|
||||
model: Argument,
|
||||
|
||||
/// Liaison optionnelle vers l'instance de variable d'état.
|
||||
///
|
||||
/// - `None` : Pas encore liée (état initial après construction)
|
||||
/// - `Some(Arc<StateVarInstance>)` : Liée et prête à l'emploi
|
||||
///
|
||||
/// Protégée par `RwLock` pour permettre la liaison post-construction
|
||||
/// et l'accès concurrent en lecture.
|
||||
variable_instance: Arc<RwLock<Option<Arc<StateVarInstance>>>>,
|
||||
}
|
||||
|
||||
pub type ArgInstanceSet = UpnpObjectSet<ArgumentInstance>;
|
||||
0
pmoupnp/src/devices/mod.rs
Normal file
0
pmoupnp/src/devices/mod.rs
Normal file
@@ -1,14 +1,34 @@
|
||||
mod object_trait;
|
||||
mod object_set;
|
||||
|
||||
pub mod variable_types;
|
||||
pub mod actions;
|
||||
pub mod mediarenderer;
|
||||
pub mod server;
|
||||
pub mod services;
|
||||
pub mod state_variables;
|
||||
pub mod value_ranges;
|
||||
|
||||
pub use crate::object_trait::UpnpObject;
|
||||
pub mod variable_types;
|
||||
|
||||
|
||||
#[derive(Clone)]
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use std::sync::RwLock;
|
||||
|
||||
pub use crate::object_trait::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpnpObjectType {
|
||||
name: String,
|
||||
object_type: String,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpnpObjectSet<T: UpnpTypedObject> {
|
||||
objects: RwLock<HashMap<String, Arc<T>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UpnpObjectSetError {
|
||||
AlreadyExists(String),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETCURRENTTRANSPORTACTIONS = "GetCurrentTransportActions" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETDEVICECAPABILITIES = "GetDeviceCapabilities" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, NUMBEROFTRACKS, CURRENTTRACK, AVTRANSPORTURI, AVTRANSPORTURIMETADATA, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETMEDIAINFO = "GetMediaInfo" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
out "NrTracks" => NUMBEROFTRACKS,
|
||||
out "CurrentTrack" => CURRENTTRACK,
|
||||
out "CurrentURI" => AVTRANSPORTURI,
|
||||
out "CurrentURIMetaData" => AVTRANSPORTURIMETADATA,
|
||||
out "NextURI" => AVTRANSPORTNEXTURI,
|
||||
out "NextURIMetaData" => AVTRANSPORTNEXTURIMETADATA,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, CURRENTTRACK, CURRENTTRACKDURATION, AVTRANSPORTURI, AVTRANSPORTURIMETADATA, RELATIVETIMEPOSITION, ABSOLUTETIMEPOSITION};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETPOSITIONINFO = "GetPositionInfo" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
out "Track" => CURRENTTRACK,
|
||||
out "TrackDuration" => CURRENTTRACKDURATION,
|
||||
out "TrackURI" => AVTRANSPORTURI,
|
||||
out "TrackMetaData" => AVTRANSPORTURIMETADATA,
|
||||
out "RelTime" => RELATIVETIMEPOSITION,
|
||||
out "AbsTime" => ABSOLUTETIMEPOSITION,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, TRANSPORTSTATE, TRANSPORTSTATUS};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETTRANSPORTINFO = "GetTransportInfo" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
out "CurrentTransportState" => TRANSPORTSTATE,
|
||||
out "CurrentTransportStatus" => TRANSPORTSTATUS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETTRANSPORTSETTINGS = "GetTransportSettings" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
30
pmoupnp/src/mediarenderer/avtransport/actions/mod.rs
Normal file
30
pmoupnp/src/mediarenderer/avtransport/actions/mod.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
mod getcurrenttransportactions;
|
||||
mod getdevicecapabilities;
|
||||
mod getmediainfo;
|
||||
mod getpositioninfo;
|
||||
mod gettransportinfo;
|
||||
mod gettransportsettings;
|
||||
mod next;
|
||||
mod pause;
|
||||
mod play;
|
||||
mod previous;
|
||||
mod seek;
|
||||
mod setavtransportnexturi;
|
||||
mod setavtransporturi;
|
||||
mod stop;
|
||||
|
||||
pub use getcurrenttransportactions::GETCURRENTTRANSPORTACTIONS;
|
||||
pub use getdevicecapabilities::GETDEVICECAPABILITIES;
|
||||
pub use getmediainfo::GETMEDIAINFO;
|
||||
pub use getpositioninfo::GETPOSITIONINFO;
|
||||
pub use gettransportinfo::GETTRANSPORTINFO;
|
||||
pub use gettransportsettings::GETTRANSPORTSETTINGS;
|
||||
pub use next::NEXT;
|
||||
pub use pause::PAUSE;
|
||||
pub use play::PLAY;
|
||||
pub use previous::PREVIOUS;
|
||||
pub use seek::SEEK;
|
||||
pub use setavtransportnexturi::SETNEXTAVTRANSPORTURI;
|
||||
pub use setavtransporturi::SETAVTRANSPORTURI;
|
||||
pub use stop::STOP;
|
||||
|
||||
8
pmoupnp/src/mediarenderer/avtransport/actions/next.rs
Normal file
8
pmoupnp/src/mediarenderer/avtransport/actions/next.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static NEXT = "Next" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
8
pmoupnp/src/mediarenderer/avtransport/actions/pause.rs
Normal file
8
pmoupnp/src/mediarenderer/avtransport/actions/pause.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static PAUSE = "Pause" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
9
pmoupnp/src/mediarenderer/avtransport/actions/play.rs
Normal file
9
pmoupnp/src/mediarenderer/avtransport/actions/play.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, TRANSPORTPLAYSPEED};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static PLAY = "Play" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
in "Speed" => TRANSPORTPLAYSPEED,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static PREVIOUS = "Previous" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
10
pmoupnp/src/mediarenderer/avtransport/actions/seek.rs
Normal file
10
pmoupnp/src/mediarenderer/avtransport/actions/seek.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, A_ARG_TYPE_SEEKMODE, CURRENTTRACKDURATION};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static SEEK = "Seek" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
in "Unit" => A_ARG_TYPE_SEEKMODE,
|
||||
in "Target" => CURRENTTRACKDURATION,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static SETNEXTAVTRANSPORTURI = "SetNextAVTransportURI" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
in "NextURI" => AVTRANSPORTNEXTURI,
|
||||
in "NextURIMetaData" => AVTRANSPORTNEXTURIMETADATA,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, AVTRANSPORTURI, AVTRANSPORTURIMETADATA};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static SETAVTRANSPORTURI = "SetAVTransportURI" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
in "CurrentURI" => AVTRANSPORTURI,
|
||||
in "CurrentURIMetaData" => AVTRANSPORTURIMETADATA,
|
||||
}
|
||||
}
|
||||
8
pmoupnp/src/mediarenderer/avtransport/actions/stop.rs
Normal file
8
pmoupnp/src/mediarenderer/avtransport/actions/stop.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static STOP = "Stop" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
156
pmoupnp/src/mediarenderer/avtransport/mod.rs
Normal file
156
pmoupnp/src/mediarenderer/avtransport/mod.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
//! # AVTransport Service - Service de contrôle de transport UPnP
|
||||
//!
|
||||
//! Ce module implémente le service AVTransport:1 selon la spécification UPnP AV.
|
||||
//! Le service AVTransport permet de contrôler la lecture de médias **audio**
|
||||
//! sur des rendus multimédias (MediaRenderer Audio).
|
||||
//!
|
||||
//! ## Fonctionnalités
|
||||
//!
|
||||
//! Le service AVTransport permet :
|
||||
//! - **Contrôle de lecture** : Play, Pause, Stop
|
||||
//! - **Navigation** : Next, Previous, Seek
|
||||
//! - **Gestion des URIs** : SetAVTransportURI, SetNextAVTransportURI
|
||||
//! - **Information d'état** : GetTransportInfo, GetPositionInfo, GetMediaInfo
|
||||
//! - **Capacités** : GetDeviceCapabilities, GetCurrentTransportActions
|
||||
//!
|
||||
//! ## Conformité UPnP
|
||||
//!
|
||||
//! Cette implémentation suit la spécification **UPnP AVTransport:1 Service Template**.
|
||||
//! Toutes les actions obligatoires (Required) sont implémentées :
|
||||
//!
|
||||
//! - ✅ SetAVTransportURI
|
||||
//! - ✅ GetMediaInfo
|
||||
//! - ✅ GetTransportInfo
|
||||
//! - ✅ GetPositionInfo
|
||||
//! - ✅ GetDeviceCapabilities
|
||||
//! - ✅ GetTransportSettings
|
||||
//! - ✅ GetCurrentTransportActions
|
||||
//! - ✅ Stop, Play, Pause
|
||||
//!
|
||||
//! Et certaines actions optionnelles :
|
||||
//! - ✅ SetNextAVTransportURI
|
||||
//! - ✅ Seek, Next, Previous
|
||||
//!
|
||||
//! ## Variables d'état
|
||||
//!
|
||||
//! Le service expose 24 variables d'état conformes à la spécification :
|
||||
//!
|
||||
//! ### État du transport
|
||||
//! - [`TRANSPORTSTATE`] : État actuel (PLAYING, STOPPED, PAUSED_PLAYBACK, etc.)
|
||||
//! - [`TRANSPORTSTATUS`] : Status du transport (OK, ERROR_OCCURRED)
|
||||
//! - [`TRANSPORTPLAYSPEED`] : Vitesse de lecture
|
||||
//!
|
||||
//! ### Information sur les pistes
|
||||
//! - [`CURRENTTRACK`] : Numéro de la piste actuelle
|
||||
//! - [`NUMBEROFTRACKS`] : Nombre total de pistes
|
||||
//! - [`CURRENTTRACKDURATION`] : Durée de la piste actuelle
|
||||
//! - [`CURRENTTRACKURI`] : URI de la piste actuelle
|
||||
//! - [`CURRENTTRACKMETADATA`] : Métadonnées de la piste
|
||||
//!
|
||||
//! ### Positionnement
|
||||
//! - [`RELATIVETIMEPOSITION`] : Position relative dans la piste
|
||||
//! - [`ABSOLUTETIMEPOSITION`] : Position absolue
|
||||
//!
|
||||
//! ### URIs et métadonnées
|
||||
//! - [`AVTRANSPORTURI`] : URI de la ressource en cours
|
||||
//! - [`AVTRANSPORTURIMETADATA`] : Métadonnées associées
|
||||
//! - [`AVTRANSPORTNEXTURI`] : URI de la ressource suivante
|
||||
//! - [`AVTRANSPORTNEXTURIMETADATA`] : Métadonnées de la ressource suivante
|
||||
//!
|
||||
//! ### Modes et capacités
|
||||
//! - [`CURRENTPLAYMODE`] : Mode de lecture (NORMAL, SHUFFLE, REPEAT_ONE, etc.)
|
||||
//! - [`PLAYBACKSTORAGEMEDIUM`] : Support de lecture (NETWORK, HDD, CD-DA, etc.)
|
||||
//! - [`POSSIBLEPLAYBACKSTORAGEMEDIA`] : Supports de lecture possibles
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! ```rust
|
||||
//! use pmoupnp::mediarenderer::avtransport::AVTTRANSPORT;
|
||||
//!
|
||||
//! // Accéder au service
|
||||
//! let service = &*AVTTRANSPORT;
|
||||
//! println!("Service: {}", service.name());
|
||||
//! println!("Type: {}", service.service_type());
|
||||
//!
|
||||
//! // Lister les actions disponibles
|
||||
//! for action in service.actions() {
|
||||
//! println!(" Action: {}", action.get_name());
|
||||
//! }
|
||||
//!
|
||||
//! // Lister les variables d'état
|
||||
//! for variable in service.variables() {
|
||||
//! println!(" Variable: {}", variable.get_name());
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Références
|
||||
//!
|
||||
//! - [UPnP AVTransport:1 Service Template](https://www.upnp.org/specs/av/UPnP-av-AVTransport-v1-Service.pdf)
|
||||
//! - [UPnP AV Architecture](https://upnp.org/specs/av/)
|
||||
|
||||
use crate::define_service;
|
||||
|
||||
pub mod variables;
|
||||
pub mod actions;
|
||||
|
||||
use actions::{
|
||||
GETCURRENTTRANSPORTACTIONS, GETDEVICECAPABILITIES, GETMEDIAINFO,
|
||||
GETPOSITIONINFO, GETTRANSPORTINFO, GETTRANSPORTSETTINGS, NEXT, PAUSE,
|
||||
PLAY, PREVIOUS, SEEK, SETNEXTAVTRANSPORTURI, SETAVTRANSPORTURI, STOP
|
||||
};
|
||||
use variables::{
|
||||
ABSOLUTETIMEPOSITION, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA,
|
||||
AVTRANSPORTURI, AVTRANSPORTURIMETADATA, A_ARG_TYPE_INSTANCE_ID,
|
||||
A_ARG_TYPE_PLAY_SPEED, A_ARG_TYPE_SEEKMODE, CURRENTMEDIADURATION,
|
||||
CURRENTPLAYMODE, CURRENTTRACK, CURRENTTRACKDURATION, CURRENTTRACKMETADATA,
|
||||
CURRENTTRACKURI, NUMBEROFTRACKS, PLAYBACKSTORAGEMEDIUM,
|
||||
POSSIBLEPLAYBACKSTORAGEMEDIA, RELATIVETIMEPOSITION, SEEKMODE,
|
||||
TRANSPORTPLAYSPEED, TRANSPORTSTATE, TRANSPORTSTATUS
|
||||
};
|
||||
|
||||
// Service AVTransport:1 conforme à la spécification UPnP AV pour MediaRenderer audio
|
||||
// Voir la documentation du module pour plus de détails
|
||||
define_service! {
|
||||
pub static AVTTRANSPORT = "AVTransport" {
|
||||
variables: [
|
||||
ABSOLUTETIMEPOSITION,
|
||||
A_ARG_TYPE_INSTANCE_ID,
|
||||
A_ARG_TYPE_PLAY_SPEED,
|
||||
A_ARG_TYPE_SEEKMODE,
|
||||
AVTRANSPORTNEXTURI,
|
||||
AVTRANSPORTNEXTURIMETADATA,
|
||||
AVTRANSPORTURI,
|
||||
AVTRANSPORTURIMETADATA,
|
||||
CURRENTMEDIADURATION,
|
||||
CURRENTPLAYMODE,
|
||||
CURRENTTRACK,
|
||||
CURRENTTRACKDURATION,
|
||||
CURRENTTRACKMETADATA,
|
||||
CURRENTTRACKURI,
|
||||
NUMBEROFTRACKS,
|
||||
PLAYBACKSTORAGEMEDIUM,
|
||||
POSSIBLEPLAYBACKSTORAGEMEDIA,
|
||||
RELATIVETIMEPOSITION,
|
||||
SEEKMODE,
|
||||
TRANSPORTPLAYSPEED,
|
||||
TRANSPORTSTATE,
|
||||
TRANSPORTSTATUS,
|
||||
],
|
||||
actions: [
|
||||
GETCURRENTTRANSPORTACTIONS,
|
||||
GETDEVICECAPABILITIES,
|
||||
GETMEDIAINFO,
|
||||
GETPOSITIONINFO,
|
||||
GETTRANSPORTINFO,
|
||||
GETTRANSPORTSETTINGS,
|
||||
NEXT,
|
||||
PAUSE,
|
||||
PLAY,
|
||||
PREVIOUS,
|
||||
SEEK,
|
||||
SETNEXTAVTRANSPORTURI,
|
||||
SETAVTRANSPORTURI,
|
||||
STOP,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static A_ARG_TYPE_INSTANCE_ID: UI4 = "A_ARG_TYPE_InstanceID"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static A_ARG_TYPE_PLAY_SPEED: String = "A_ARG_TYPE_PlaySpeed"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static A_ARG_TYPE_SEEKMODE: String = "A_ARG_TYPE_SeekMode" {
|
||||
allowed: ["TRACK_NR", "REL_TIME", "ABS_TIME"],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static AVTRANSPORTURI: String = "AVTransportURI"
|
||||
}
|
||||
|
||||
define_variable! {
|
||||
pub static AVTRANSPORTNEXTURI: String = "AVTransportNextURI"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::state_variables::{StateVariable, StateVariableError};
|
||||
use crate::variable_types::StateVarType;
|
||||
use bevy_reflect::Reflect;
|
||||
use once_cell::sync::Lazy;
|
||||
use pmodidl::{DIDLLite, MediaMetadataParser};
|
||||
|
||||
|
||||
fn avtransporturimetadataparser(value: &str) -> Result<Box<dyn Reflect>, StateVariableError> {
|
||||
// Parse DIDL-Lite
|
||||
let didl = DIDLLite::parse(value)
|
||||
.map_err(|e| StateVariableError::ParseError(format!("Failed to parse DIDL-Lite: {}", e)))?;
|
||||
|
||||
// Retourne le résultat sous forme de Box<dyn Reflect>
|
||||
Ok(Box::new(didl) as Box<dyn Reflect>)
|
||||
}
|
||||
|
||||
pub static AVTRANSPORTURIMETADATA: Lazy<Arc<StateVariable>> = Lazy::new(|| -> Arc<StateVariable> {
|
||||
let mut sv = StateVariable::new(StateVarType::String, "AVTransportURIMetaData".to_string());
|
||||
|
||||
sv.set_value_parser(Arc::new(avtransporturimetadataparser)).expect("Failed to set parser");
|
||||
Arc::new(sv)
|
||||
});
|
||||
|
||||
pub static AVTRANSPORTNEXTURIMETADATA: Lazy<Arc<StateVariable>> = Lazy::new(|| -> Arc<StateVariable> {
|
||||
let mut sv = StateVariable::new(StateVarType::String, "AVTransportNextURIMetaData".to_string());
|
||||
|
||||
sv.set_value_parser(Arc::new(avtransporturimetadataparser)).expect("Failed to set parser");
|
||||
Arc::new(sv)
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static CURRENTMEDIADURATION: String = "CurrentMediaDuration"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static CURRENTPLAYMODE: String = "CurrentPlayMode" {
|
||||
allowed: ["NORMAL", "SHUFFLE", "REPEAT_ONE", "REPEAT_ALL", "RANDOM", "DIRECT_1", "INTRO"],
|
||||
default: "NORMAL",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static CURRENTTRACKMETADATA: String = "CurrentTrackMetaData"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static CURRENTTRACKURI: String = "CurrentTrackURI"
|
||||
}
|
||||
47
pmoupnp/src/mediarenderer/avtransport/variables/mod.rs
Normal file
47
pmoupnp/src/mediarenderer/avtransport/variables/mod.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
mod a_arg_type_instanceid;
|
||||
mod a_arg_type_playspeed;
|
||||
mod a_arg_type_seekmode;
|
||||
mod avtransporturi;
|
||||
mod avtransporturimetadata;
|
||||
mod currentmediaduration;
|
||||
mod currentplaymode;
|
||||
mod currenttrackmetadata;
|
||||
mod currenttrackuri;
|
||||
mod playbackstoragemedium;
|
||||
mod possibleplaybackstoragemedia;
|
||||
mod possiblerecordstoragemedia;
|
||||
mod recordstoragemedium;
|
||||
mod seekmode;
|
||||
mod track;
|
||||
mod trackduration;
|
||||
mod transportplayspeed;
|
||||
mod transportstate;
|
||||
mod transportstatus;
|
||||
|
||||
pub use a_arg_type_instanceid::A_ARG_TYPE_INSTANCE_ID;
|
||||
pub use a_arg_type_playspeed::A_ARG_TYPE_PLAY_SPEED;
|
||||
pub use a_arg_type_seekmode::A_ARG_TYPE_SEEKMODE;
|
||||
pub use avtransporturi::AVTRANSPORTURI;
|
||||
pub use avtransporturi::AVTRANSPORTNEXTURI;
|
||||
pub use avtransporturimetadata::AVTRANSPORTURIMETADATA;
|
||||
pub use avtransporturimetadata::AVTRANSPORTNEXTURIMETADATA;
|
||||
pub use currentmediaduration::CURRENTMEDIADURATION;
|
||||
pub use currentplaymode::CURRENTPLAYMODE;
|
||||
pub use currenttrackmetadata::CURRENTTRACKMETADATA;
|
||||
pub use currenttrackuri::CURRENTTRACKURI;
|
||||
pub use playbackstoragemedium::PLAYBACKSTORAGEMEDIUM;
|
||||
pub use possibleplaybackstoragemedia::POSSIBLEPLAYBACKSTORAGEMEDIA;
|
||||
pub use possiblerecordstoragemedia::POSSIBLERECORDSTORAGEMEDIA;
|
||||
pub use recordstoragemedium::RECORDSTORAGEMEDIUM;
|
||||
pub use seekmode::SEEKMODE;
|
||||
pub use track::CURRENTTRACK;
|
||||
pub use track::NUMBEROFTRACKS;
|
||||
pub use trackduration::ABSOLUTETIMEPOSITION;
|
||||
pub use trackduration::CURRENTTRACKDURATION;
|
||||
pub use trackduration::RELATIVETIMEPOSITION;
|
||||
pub use transportplayspeed::TRANSPORTPLAYSPEED;
|
||||
pub use transportstate::TRANSPORTSTATE;
|
||||
pub use transportstatus::TRANSPORTSTATUS;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::define_variable;
|
||||
|
||||
// Valeurs pour un MediaRenderer audio uniquement (suppression des formats vidéo)
|
||||
define_variable! {
|
||||
pub static PLAYBACKSTORAGEMEDIUM: String = "PlaybackStorageMedium" {
|
||||
allowed: [
|
||||
"UNKNOWN", "CD-ROM", "CD-DA", "CD-R", "CD-RW", "SACD",
|
||||
"MD-AUDIO", "DVD-AUDIO", "DAT", "HDD", "NETWORK",
|
||||
"NONE", "NOT_IMPLEMENTED"
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static POSSIBLEPLAYBACKSTORAGEMEDIA: String = "PossiblePlaybackStorageMedia"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::state_variables::StateVariable;
|
||||
use crate::variable_types::StateVarType;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
pub static POSSIBLERECORDSTORAGEMEDIA: Lazy<Arc<StateVariable>> = Lazy::new(|| -> Arc<StateVariable> {
|
||||
let sv = StateVariable::new(StateVarType::String, "PossibleRecordStorageMedia".to_string());
|
||||
Arc::new(sv)
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::state_variables::StateVariable;
|
||||
use crate::variable_types::StateVarType;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
pub static RECORDSTORAGEMEDIUM: Lazy<Arc<StateVariable>> = Lazy::new(|| -> Arc<StateVariable> {
|
||||
let sv = StateVariable::new(StateVarType::String, "RecordStorageMedium".to_string());
|
||||
Arc::new(sv)
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static SEEKMODE: String = "SeekMode"
|
||||
}
|
||||
|
||||
14
pmoupnp/src/mediarenderer/avtransport/variables/track.rs
Normal file
14
pmoupnp/src/mediarenderer/avtransport/variables/track.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static CURRENTTRACK: String = "CurrentTrack" {
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
|
||||
define_variable! {
|
||||
pub static NUMBEROFTRACKS: String = "NumberOfTracks" {
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static CURRENTTRACKDURATION: String = "CurrentTrackDuration" {
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
|
||||
define_variable! {
|
||||
pub static ABSOLUTETIMEPOSITION: String = "AbsoluteTimePosition" {
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
|
||||
define_variable! {
|
||||
pub static RELATIVETIMEPOSITION: String = "RelativeTimePosition" {
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static TRANSPORTPLAYSPEED: String = "TransportPlaySpeed" {
|
||||
allowed: ["1"],
|
||||
default: "1",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::define_variable;
|
||||
|
||||
// États pour un MediaRenderer audio uniquement (suppression des états d'enregistrement)
|
||||
define_variable! {
|
||||
pub static TRANSPORTSTATE: String = "TransportState" {
|
||||
allowed: ["STOPPED", "PLAYING", "TRANSITIONING", "PAUSED_PLAYBACK", "NO_MEDIA_PRESENT"],
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static TRANSPORTSTATUS: String = "TransportStatus" {
|
||||
allowed: ["OK", "ERROR_OCCURRED"],
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::connectionmanager::variables::CURRENTCONNECTIONIDS;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETCURRENTCONNECTIONIDS = "GetCurrentConnectionIDs" {
|
||||
out "ConnectionIDs" => CURRENTCONNECTIONIDS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use crate::mediarenderer::connectionmanager::variables::{
|
||||
A_ARG_TYPE_CONNECTIONID, A_ARG_TYPE_RCSID, A_ARG_TYPE_AVTRANSPORTID,
|
||||
A_ARG_TYPE_PROTOCOLINFO, A_ARG_TYPE_DIRECTION, A_ARG_TYPE_CONNECTIONSTATUS
|
||||
};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETCURRENTCONNECTIONINFO = "GetCurrentConnectionInfo" {
|
||||
in "ConnectionID" => A_ARG_TYPE_CONNECTIONID,
|
||||
out "RcsID" => A_ARG_TYPE_RCSID,
|
||||
out "AVTransportID" => A_ARG_TYPE_AVTRANSPORTID,
|
||||
out "ProtocolInfo" => A_ARG_TYPE_PROTOCOLINFO,
|
||||
out "PeerConnectionManager" => A_ARG_TYPE_PROTOCOLINFO,
|
||||
out "PeerConnectionID" => A_ARG_TYPE_CONNECTIONID,
|
||||
out "Direction" => A_ARG_TYPE_DIRECTION,
|
||||
out "Status" => A_ARG_TYPE_CONNECTIONSTATUS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use crate::mediarenderer::connectionmanager::variables::{SOURCEPROTOCOLINFO, SINKPROTOCOLINFO};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETPROTOCOLINFO = "GetProtocolInfo" {
|
||||
out "Source" => SOURCEPROTOCOLINFO,
|
||||
out "Sink" => SINKPROTOCOLINFO,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod getprotocolinfo;
|
||||
mod getcurrentconnectionids;
|
||||
mod getcurrentconnectioninfo;
|
||||
|
||||
pub use getprotocolinfo::GETPROTOCOLINFO;
|
||||
pub use getcurrentconnectionids::GETCURRENTCONNECTIONIDS;
|
||||
pub use getcurrentconnectioninfo::GETCURRENTCONNECTIONINFO;
|
||||
89
pmoupnp/src/mediarenderer/connectionmanager/mod.rs
Normal file
89
pmoupnp/src/mediarenderer/connectionmanager/mod.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! # ConnectionManager Service - Service de gestion des connexions UPnP
|
||||
//!
|
||||
//! Ce module implémente le service ConnectionManager:1 selon la spécification UPnP AV.
|
||||
//! Le service ConnectionManager gère les connexions entre MediaServer et MediaRenderer,
|
||||
//! et expose les protocoles et formats supportés.
|
||||
//!
|
||||
//! ## Fonctionnalités
|
||||
//!
|
||||
//! Le service ConnectionManager permet :
|
||||
//! - **Énumération des protocoles** : GetProtocolInfo
|
||||
//! - **Gestion des connexions** : GetCurrentConnectionIDs, GetCurrentConnectionInfo
|
||||
//! - Support des formats audio (MP3, FLAC, WAV, etc.)
|
||||
//!
|
||||
//! ## Conformité UPnP
|
||||
//!
|
||||
//! Cette implémentation suit la spécification **UPnP ConnectionManager:1 Service Template**.
|
||||
//! Toutes les actions obligatoires (Required) sont implémentées :
|
||||
//!
|
||||
//! - ✅ GetProtocolInfo
|
||||
//! - ✅ GetCurrentConnectionIDs
|
||||
//! - ✅ GetCurrentConnectionInfo
|
||||
//!
|
||||
//! ## Variables d'état
|
||||
//!
|
||||
//! Le service expose les variables d'état conformes à la spécification :
|
||||
//!
|
||||
//! ### Informations de protocole
|
||||
//! - [`SOURCEPROTOCOLINFO`] : Protocoles source (vide pour un renderer)
|
||||
//! - [`SINKPROTOCOLINFO`] : Protocoles sink supportés (http-get:*:audio/mpeg:*, etc.)
|
||||
//! - [`CURRENTCONNECTIONIDS`] : IDs des connexions actives
|
||||
//!
|
||||
//! ### Arguments
|
||||
//! - [`A_ARG_TYPE_CONNECTIONID`] : ID de connexion
|
||||
//! - [`A_ARG_TYPE_CONNECTIONSTATUS`] : Statut de connexion
|
||||
//! - [`A_ARG_TYPE_DIRECTION`] : Direction (Input/Output)
|
||||
//! - [`A_ARG_TYPE_PROTOCOLINFO`] : Information de protocole
|
||||
//! - [`A_ARG_TYPE_RCSID`] : ID RenderingControl
|
||||
//! - [`A_ARG_TYPE_AVTRANSPORTID`] : ID AVTransport
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! ```rust
|
||||
//! use pmoupnp::mediarenderer::connectionmanager::CONNECTIONMANAGER;
|
||||
//!
|
||||
//! // Accéder au service
|
||||
//! let service = &*CONNECTIONMANAGER;
|
||||
//! println!("Service: {}", service.name());
|
||||
//! println!("Type: {}", service.service_type());
|
||||
//! ```
|
||||
//!
|
||||
//! ## Références
|
||||
//!
|
||||
//! - [UPnP ConnectionManager:1 Service Template](https://upnp.org/specs/av/UPnP-av-ConnectionManager-v1-Service.pdf)
|
||||
//! - [UPnP AV Architecture](https://upnp.org/specs/av/)
|
||||
|
||||
use crate::define_service;
|
||||
|
||||
pub mod variables;
|
||||
pub mod actions;
|
||||
|
||||
use actions::{GETCURRENTCONNECTIONIDS, GETCURRENTCONNECTIONINFO, GETPROTOCOLINFO};
|
||||
use variables::{
|
||||
A_ARG_TYPE_AVTRANSPORTID, A_ARG_TYPE_CONNECTIONID, A_ARG_TYPE_CONNECTIONSTATUS,
|
||||
A_ARG_TYPE_DIRECTION, A_ARG_TYPE_PROTOCOLINFO, A_ARG_TYPE_RCSID,
|
||||
CURRENTCONNECTIONIDS, SINKPROTOCOLINFO, SOURCEPROTOCOLINFO
|
||||
};
|
||||
|
||||
// Service ConnectionManager:1 conforme à la spécification UPnP AV pour MediaRenderer audio
|
||||
// Voir la documentation du module pour plus de détails
|
||||
define_service! {
|
||||
pub static CONNECTIONMANAGER = "ConnectionManager" {
|
||||
variables: [
|
||||
A_ARG_TYPE_AVTRANSPORTID,
|
||||
A_ARG_TYPE_CONNECTIONID,
|
||||
A_ARG_TYPE_CONNECTIONSTATUS,
|
||||
A_ARG_TYPE_DIRECTION,
|
||||
A_ARG_TYPE_PROTOCOLINFO,
|
||||
A_ARG_TYPE_RCSID,
|
||||
CURRENTCONNECTIONIDS,
|
||||
SINKPROTOCOLINFO,
|
||||
SOURCEPROTOCOLINFO,
|
||||
],
|
||||
actions: [
|
||||
GETCURRENTCONNECTIONIDS,
|
||||
GETCURRENTCONNECTIONINFO,
|
||||
GETPROTOCOLINFO,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static A_ARG_TYPE_AVTRANSPORTID: I4 = "A_ARG_TYPE_AVTransportID"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static A_ARG_TYPE_CONNECTIONID: I4 = "A_ARG_TYPE_ConnectionID"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static A_ARG_TYPE_CONNECTIONSTATUS: String = "A_ARG_TYPE_ConnectionStatus" {
|
||||
allowed: ["OK", "ContentFormatMismatch", "InsufficientBandwidth", "UnreliableChannel", "Unknown"],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static A_ARG_TYPE_DIRECTION: String = "A_ARG_TYPE_Direction" {
|
||||
allowed: ["Input", "Output"],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static A_ARG_TYPE_PROTOCOLINFO: String = "A_ARG_TYPE_ProtocolInfo"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static A_ARG_TYPE_RCSID: I4 = "A_ARG_TYPE_RcsID"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static CURRENTCONNECTIONIDS: String = "CurrentConnectionIDs" {
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
19
pmoupnp/src/mediarenderer/connectionmanager/variables/mod.rs
Normal file
19
pmoupnp/src/mediarenderer/connectionmanager/variables/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
mod sourceprotocolinfo;
|
||||
mod sinkprotocolinfo;
|
||||
mod currentconnectionids;
|
||||
mod a_arg_type_connectionid;
|
||||
mod a_arg_type_connectionstatus;
|
||||
mod a_arg_type_direction;
|
||||
mod a_arg_type_protocolinfo;
|
||||
mod a_arg_type_rcsid;
|
||||
mod a_arg_type_avtransportid;
|
||||
|
||||
pub use sourceprotocolinfo::SOURCEPROTOCOLINFO;
|
||||
pub use sinkprotocolinfo::SINKPROTOCOLINFO;
|
||||
pub use currentconnectionids::CURRENTCONNECTIONIDS;
|
||||
pub use a_arg_type_connectionid::A_ARG_TYPE_CONNECTIONID;
|
||||
pub use a_arg_type_connectionstatus::A_ARG_TYPE_CONNECTIONSTATUS;
|
||||
pub use a_arg_type_direction::A_ARG_TYPE_DIRECTION;
|
||||
pub use a_arg_type_protocolinfo::A_ARG_TYPE_PROTOCOLINFO;
|
||||
pub use a_arg_type_rcsid::A_ARG_TYPE_RCSID;
|
||||
pub use a_arg_type_avtransportid::A_ARG_TYPE_AVTRANSPORTID;
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::define_variable;
|
||||
|
||||
// Pour un MediaRenderer audio, liste les protocoles/formats audio supportés
|
||||
define_variable! {
|
||||
pub static SINKPROTOCOLINFO: String = "SinkProtocolInfo" {
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static SOURCEPROTOCOLINFO: String = "SourceProtocolInfo" {
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
3
pmoupnp/src/mediarenderer/mod.rs
Normal file
3
pmoupnp/src/mediarenderer/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod avtransport;
|
||||
pub mod connectionmanager;
|
||||
pub mod renderingcontrol;
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::mediarenderer::renderingcontrol::variables::{A_ARG_TYPE_INSTANCE_ID, A_ARG_TYPE_CHANNEL, MUTE};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETMUTE = "GetMute" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
in "Channel" => A_ARG_TYPE_CHANNEL,
|
||||
out "CurrentMute" => MUTE,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::mediarenderer::renderingcontrol::variables::{A_ARG_TYPE_INSTANCE_ID, A_ARG_TYPE_CHANNEL, VOLUME};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETVOLUME = "GetVolume" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
in "Channel" => A_ARG_TYPE_CHANNEL,
|
||||
out "CurrentVolume" => VOLUME,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod getvolume;
|
||||
mod setvolume;
|
||||
mod getmute;
|
||||
mod setmute;
|
||||
|
||||
pub use getvolume::GETVOLUME;
|
||||
pub use setvolume::SETVOLUME;
|
||||
pub use getmute::GETMUTE;
|
||||
pub use setmute::SETMUTE;
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::mediarenderer::renderingcontrol::variables::{A_ARG_TYPE_INSTANCE_ID, A_ARG_TYPE_CHANNEL, MUTE};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static SETMUTE = "SetMute" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
in "Channel" => A_ARG_TYPE_CHANNEL,
|
||||
in "DesiredMute" => MUTE,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::mediarenderer::renderingcontrol::variables::{A_ARG_TYPE_INSTANCE_ID, A_ARG_TYPE_CHANNEL, VOLUME};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static SETVOLUME = "SetVolume" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
in "Channel" => A_ARG_TYPE_CHANNEL,
|
||||
in "DesiredVolume" => VOLUME,
|
||||
}
|
||||
}
|
||||
77
pmoupnp/src/mediarenderer/renderingcontrol/mod.rs
Normal file
77
pmoupnp/src/mediarenderer/renderingcontrol/mod.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! # RenderingControl Service - Service de contrôle du rendu audio UPnP
|
||||
//!
|
||||
//! Ce module implémente le service RenderingControl:1 selon la spécification UPnP AV.
|
||||
//! Le service RenderingControl permet de contrôler les paramètres de rendu audio
|
||||
//! (volume, mute, etc.) sur un MediaRenderer.
|
||||
//!
|
||||
//! ## Fonctionnalités
|
||||
//!
|
||||
//! Le service RenderingControl permet :
|
||||
//! - **Contrôle du volume** : GetVolume, SetVolume
|
||||
//! - **Contrôle du mute** : GetMute, SetMute
|
||||
//! - Support multi-canal (Master, LF, RF)
|
||||
//!
|
||||
//! ## Conformité UPnP
|
||||
//!
|
||||
//! Cette implémentation suit la spécification **UPnP RenderingControl:1 Service Template**.
|
||||
//! Toutes les actions obligatoires (Required) sont implémentées :
|
||||
//!
|
||||
//! - ✅ GetVolume
|
||||
//! - ✅ SetVolume
|
||||
//! - ✅ GetMute
|
||||
//! - ✅ SetMute
|
||||
//!
|
||||
//! ## Variables d'état
|
||||
//!
|
||||
//! Le service expose les variables d'état conformes à la spécification :
|
||||
//!
|
||||
//! ### Contrôle audio
|
||||
//! - [`VOLUME`] : Niveau de volume (0-100)
|
||||
//! - [`MUTE`] : État mute (true/false)
|
||||
//!
|
||||
//! ### Arguments
|
||||
//! - [`A_ARG_TYPE_INSTANCE_ID`] : ID d'instance
|
||||
//! - [`A_ARG_TYPE_CHANNEL`] : Canal audio (Master, LF, RF)
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! ```rust
|
||||
//! use pmoupnp::mediarenderer::renderingcontrol::RENDERINGCONTROL;
|
||||
//!
|
||||
//! // Accéder au service
|
||||
//! let service = &*RENDERINGCONTROL;
|
||||
//! println!("Service: {}", service.name());
|
||||
//! println!("Type: {}", service.service_type());
|
||||
//! ```
|
||||
//!
|
||||
//! ## Références
|
||||
//!
|
||||
//! - [UPnP RenderingControl:1 Service Template](https://upnp.org/specs/av/UPnP-av-RenderingControl-v1-Service.pdf)
|
||||
//! - [UPnP AV Architecture](https://upnp.org/specs/av/)
|
||||
|
||||
use crate::define_service;
|
||||
|
||||
pub mod variables;
|
||||
pub mod actions;
|
||||
|
||||
use actions::{GETMUTE, GETVOLUME, SETMUTE, SETVOLUME};
|
||||
use variables::{A_ARG_TYPE_CHANNEL, A_ARG_TYPE_INSTANCE_ID, MUTE, VOLUME};
|
||||
|
||||
// Service RenderingControl:1 conforme à la spécification UPnP AV pour MediaRenderer audio
|
||||
// Voir la documentation du module pour plus de détails
|
||||
define_service! {
|
||||
pub static RENDERINGCONTROL = "RenderingControl" {
|
||||
variables: [
|
||||
A_ARG_TYPE_CHANNEL,
|
||||
A_ARG_TYPE_INSTANCE_ID,
|
||||
MUTE,
|
||||
VOLUME,
|
||||
],
|
||||
actions: [
|
||||
GETMUTE,
|
||||
GETVOLUME,
|
||||
SETMUTE,
|
||||
SETVOLUME,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static A_ARG_TYPE_CHANNEL: String = "A_ARG_TYPE_Channel" {
|
||||
allowed: ["Master", "LF", "RF"],
|
||||
default: "Master",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static A_ARG_TYPE_INSTANCE_ID: UI4 = "A_ARG_TYPE_InstanceID"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod a_arg_type_instanceid;
|
||||
mod a_arg_type_channel;
|
||||
mod volume;
|
||||
mod mute;
|
||||
|
||||
pub use a_arg_type_instanceid::A_ARG_TYPE_INSTANCE_ID;
|
||||
pub use a_arg_type_channel::A_ARG_TYPE_CHANNEL;
|
||||
pub use volume::VOLUME;
|
||||
pub use mute::MUTE;
|
||||
@@ -0,0 +1,7 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static MUTE: Boolean = "Mute" {
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use crate::define_variable;
|
||||
|
||||
define_variable! {
|
||||
pub static VOLUME: UI2 = "Volume" {
|
||||
evented: true,
|
||||
}
|
||||
}
|
||||
197
pmoupnp/src/object_set.rs
Normal file
197
pmoupnp/src/object_set.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::{UpnpDeepClone, UpnpObjectSet, UpnpObjectSetError, UpnpSet, UpnpTypedObject};
|
||||
|
||||
/// Implémentation du clonage profond pour `UpnpObjectSet`.
|
||||
///
|
||||
/// Cette implémentation crée une copie complète et indépendante du set,
|
||||
/// en clonant chaque objet `T` et en créant de nouveaux `Arc` autour de ces clones.
|
||||
/// Les modifications sur l'un des sets n'affectent pas l'autre.
|
||||
impl<T: UpnpTypedObject> UpnpDeepClone for UpnpObjectSet<T> {
|
||||
fn deep_clone(&self) -> Self {
|
||||
let guard = self.objects.read().unwrap();
|
||||
|
||||
let cloned_map: HashMap<String, Arc<T>> = guard
|
||||
.iter()
|
||||
.map(|(key, arc)| (key.clone(), Arc::new((**arc).clone())))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
objects: RwLock::new(cloned_map),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémentation du clonage superficiel pour `UpnpObjectSet`.
|
||||
///
|
||||
/// Cette implémentation crée une copie du set qui **partage** les objets `T`
|
||||
/// via les `Arc`. C'est beaucoup plus rapide et économe en mémoire qu'un clonage
|
||||
/// profond, car seuls les pointeurs `Arc` sont clonés (incrémentation du compteur
|
||||
/// de références).
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Les deux sets partagent les mêmes instances d'objets `T`. Si `T` contient
|
||||
/// de la mutabilité interne (via `Mutex`, `RwLock`, etc.), les modifications
|
||||
/// seront visibles depuis les deux sets.
|
||||
impl<T: UpnpTypedObject> Clone for UpnpObjectSet<T> {
|
||||
fn clone(&self) -> Self {
|
||||
let guard = self.objects.read().unwrap();
|
||||
|
||||
Self {
|
||||
objects: RwLock::new(guard.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: UpnpTypedObject> UpnpObjectSet<T> {
|
||||
/// Crée un nouveau `UpnpObjectSet` vide.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let set: UpnpObjectSet<MyObject> = UpnpObjectSet::new();
|
||||
/// ```
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
objects: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insère un objet dans le set.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `object` - L'objet à insérer, encapsulé dans un `Arc`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Si l'insertion a réussi
|
||||
/// * `Err(UpnpObjectSetError::AlreadyExists)` - Si un objet avec le même nom existe déjà
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let mut set = UpnpObjectSet::new();
|
||||
/// let obj = Arc::new(MyObject::new("test"));
|
||||
/// set.insert(obj)?;
|
||||
/// ```
|
||||
pub fn insert(&mut self, object: Arc<T>) -> Result<(), UpnpObjectSetError> {
|
||||
let mut guard = self.objects.write().unwrap();
|
||||
let key = object.get_name().to_string();
|
||||
|
||||
if guard.contains_key(&key) {
|
||||
return Err(UpnpObjectSetError::AlreadyExists(key));
|
||||
}
|
||||
|
||||
guard.insert(key, object);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insère un objet dans le set, ou remplace l'objet existant s'il y en a un avec le même nom.
|
||||
///
|
||||
/// Cette méthode ne retourne jamais d'erreur et écrase silencieusement tout objet existant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `object` - L'objet à insérer ou remplacer, encapsulé dans un `Arc`
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let mut set = UpnpObjectSet::new();
|
||||
/// let obj1 = Arc::new(MyObject::new("test"));
|
||||
/// let obj2 = Arc::new(MyObject::new("test")); // Même nom
|
||||
///
|
||||
/// set.insert_or_replace(obj1);
|
||||
/// set.insert_or_replace(obj2); // Remplace obj1
|
||||
/// ```
|
||||
pub fn insert_or_replace(&mut self, object: Arc<T>) {
|
||||
let mut guard = self.objects.write().unwrap();
|
||||
let key: String = object.get_name().to_string();
|
||||
|
||||
guard.insert(key, object);
|
||||
}
|
||||
|
||||
/// Vérifie si le set contient un objet donné.
|
||||
///
|
||||
/// La vérification se base sur le nom de l'objet retourné par `get_name()`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `object` - L'objet à rechercher
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` si un objet avec le même nom existe dans le set, `false` sinon.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let set = UpnpObjectSet::new();
|
||||
/// let obj = Arc::new(MyObject::new("test"));
|
||||
///
|
||||
/// if set.contains(obj.clone()) {
|
||||
/// println!("L'objet existe déjà");
|
||||
/// }
|
||||
/// ```
|
||||
pub fn contains(&self, object: Arc<T>) -> bool {
|
||||
let guard = self.objects.read().unwrap();
|
||||
let key: String = object.get_name().to_string();
|
||||
|
||||
guard.contains_key(&key)
|
||||
}
|
||||
|
||||
/// Récupère un objet par son nom.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - Le nom de l'objet à rechercher
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Some(Arc<T>)` - Si un objet avec ce nom existe
|
||||
/// * `None` - Si aucun objet n'est trouvé
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let set = UpnpObjectSet::new();
|
||||
///
|
||||
/// if let Some(obj) = set.get_by_name("test") {
|
||||
/// println!("Objet trouvé: {}", obj.get_name());
|
||||
/// }
|
||||
/// ```
|
||||
pub fn get_by_name(&self, name: &str) -> Option<Arc<T>> {
|
||||
let guard = self.objects.read().unwrap();
|
||||
guard.get(name).cloned()
|
||||
}
|
||||
|
||||
/// Retourne tous les objets du set.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Un vecteur contenant des clones des `Arc` pointant vers tous les objets du set.
|
||||
/// L'ordre des éléments n'est pas garanti.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let set = UpnpObjectSet::new();
|
||||
///
|
||||
/// for obj in set.all() {
|
||||
/// println!("Objet: {}", obj.get_name());
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Thread-safety
|
||||
///
|
||||
/// Cette méthode acquiert un verrou de lecture. Plusieurs threads peuvent
|
||||
/// appeler cette méthode simultanément sans blocage.
|
||||
pub fn all(&self) -> Vec<Arc<T>> {
|
||||
let guard = self.objects.read().unwrap();
|
||||
guard.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,766 @@
|
||||
//! ## Hiérarchie des traits
|
||||
//!
|
||||
//! ```text
|
||||
//! Clone + Debug
|
||||
//! └─> UpnpObject (trait de base)
|
||||
//! ├─> UpnpModel (modèles créant des instances)
|
||||
//! ├─> UpnpInstance (instances concrètes)
|
||||
//! ├─> UpnpTyped (objets avec nom et type)
|
||||
//! │ └─> UpnpTypedObject = UpnpObject + UpnpTyped
|
||||
//! │ └─> UpnpTypedInstance = UpnpTypedObject + UpnpInstance
|
||||
//! └─> UpnpSet (collections) + UpnpDeepClone
|
||||
//! ├─> UpnpModelSet = UpnpSet + UpnpModel
|
||||
//! └─> UpnInstanceSet = UpnpSet + UpnpInstance
|
||||
//!
|
||||
//! UpnpDeepClone (indépendant)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Description des traits
|
||||
//!
|
||||
//! - **Traits de base** :
|
||||
//! - [`UpnpObject`] : Trait principal avec sérialisation XML/Markdown
|
||||
//! - [`UpnpDeepClone`] : Clonage profond (indépendant de la hiérarchie)
|
||||
//!
|
||||
//! - **Traits de spécialisation niveau 1** :
|
||||
//! - [`UpnpModel`] : Modèle pouvant créer des instances
|
||||
//! - [`UpnpInstance`] : Instance concrète créée depuis un modèle
|
||||
//! - [`UpnpTyped`] : Ajoute les informations de type et nom
|
||||
//! - [`UpnpSet`] : Marque un objet comme collection
|
||||
//!
|
||||
//! - **Traits combinés niveau 2** :
|
||||
//! - [`UpnpTypedObject`] : Objet typé (marker trait)
|
||||
//!
|
||||
//! - **Traits combinés niveau 3** :
|
||||
//! - [`UpnpTypedInstance`] : Instance typée (marker trait)
|
||||
//! - [`UpnpModelSet`] : Collection de modèles (marker trait)
|
||||
//! - [`UpnInstanceSet`] : Collection d'instances (marker trait)
|
||||
|
||||
use std::{fmt::Debug, sync::Arc};
|
||||
|
||||
use xmltree::{Element, EmitterConfig};
|
||||
|
||||
use crate::UpnpObjectType;
|
||||
|
||||
/// Trait pour le clonage profond d'objets UPnP.
|
||||
///
|
||||
/// Contrairement au trait standard [`Clone`] qui peut effectuer un clonage superficiel
|
||||
/// (partage via `Arc`), ce trait garantit un clonage complet et indépendant de l'objet.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Ce trait est indépendant de la hiérarchie [`UpnpObject`] et peut être implémenté
|
||||
/// séparément.
|
||||
pub trait UpnpDeepClone {
|
||||
/// Crée un clone profond de l'objet.
|
||||
///
|
||||
/// Tous les éléments internes sont clonés, créant un objet complètement indépendant.
|
||||
fn deep_clone(&self) -> Self;
|
||||
}
|
||||
|
||||
pub trait UpnpObject {
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType;
|
||||
/// Trait de base pour tous les objets UPnP.
|
||||
///
|
||||
/// Ce trait fournit les fonctionnalités communes à tous les objets UPnP :
|
||||
/// - Sérialisation XML
|
||||
/// - Conversion en Markdown
|
||||
/// - Identification du type d'objet (instance ou set)
|
||||
///
|
||||
/// # Traits requis
|
||||
///
|
||||
/// - [`Clone`] : Pour pouvoir dupliquer les objets
|
||||
/// - [`Debug`] : Pour le débogage
|
||||
///
|
||||
/// # Hiérarchie
|
||||
///
|
||||
/// Ce trait est à la base de toute la hiérarchie UPnP. Voir la documentation du module
|
||||
/// pour le graphe complet.
|
||||
pub trait UpnpObject: Clone + Debug {
|
||||
/// Convertit l'objet en élément XML.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Un [`Element`] xmltree représentant l'objet.
|
||||
fn to_xml_element(&self) -> Element;
|
||||
|
||||
fn get_name(&self)-> &String {
|
||||
return &self.as_upnp_object_type().name;
|
||||
}
|
||||
|
||||
fn get_object_type(&self) -> &String {
|
||||
&self.as_upnp_object_type().object_type
|
||||
}
|
||||
|
||||
/// Convertit l'objet en chaîne XML formatée.
|
||||
///
|
||||
/// Génère une représentation XML complète avec en-tête et indentation.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Une chaîne XML formatée avec :
|
||||
/// - En-tête `<?xml version="1.0" encoding="UTF-8"?>`
|
||||
/// - Indentation de 2 espaces
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let xml = my_object.to_xml();
|
||||
/// println!("{}", xml);
|
||||
/// // <?xml version="1.0" encoding="UTF-8"?>
|
||||
/// // <element>
|
||||
/// // <child>value</child>
|
||||
/// // </element>
|
||||
/// ```
|
||||
fn to_xml(&self) -> String {
|
||||
let elem = self.to_xml_element();
|
||||
|
||||
// Configurer l'indentation
|
||||
let config = EmitterConfig::new()
|
||||
.perform_indent(true)
|
||||
.indent_string(" "); // 2 espaces
|
||||
.indent_string(" ");
|
||||
|
||||
// Sérialiser dans un buffer
|
||||
let mut buf = Vec::new();
|
||||
// écrire l'élément
|
||||
elem.write_with_config(&mut buf, config)
|
||||
.expect("Failed to write XML");
|
||||
|
||||
// Préfixer avec l'en-tête XML
|
||||
let mut xml_string = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n".to_string();
|
||||
xml_string.push_str(&String::from_utf8(buf).expect("Invalid UTF-8"));
|
||||
|
||||
xml_string
|
||||
}
|
||||
|
||||
}
|
||||
/// Convertit l'objet en représentation Markdown.
|
||||
///
|
||||
/// Génère une vue hiérarchique de la structure XML en format Markdown,
|
||||
/// avec détection automatique des URLs et images.
|
||||
///
|
||||
/// # Fonctionnalités
|
||||
///
|
||||
/// - Les URLs sont converties en liens cliquables
|
||||
/// - Les URLs d'images sont affichées comme images
|
||||
/// - Les attributs sont formatés comme `key=value`
|
||||
/// - Structure hiérarchique avec indentation
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Une chaîne Markdown formatée.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let md = my_object.to_markdown();
|
||||
/// println!("{}", md);
|
||||
/// // # UPnP XML (Markdown view)
|
||||
/// //
|
||||
/// // - **element**
|
||||
/// // - **child**: `value`
|
||||
/// ```
|
||||
fn to_markdown(&self) -> String {
|
||||
let elem = self.to_xml_element();
|
||||
let mut md = String::new();
|
||||
|
||||
fn is_url(s: &str) -> bool {
|
||||
s.starts_with("http://") || s.starts_with("https://") || s.starts_with("urn:")
|
||||
}
|
||||
|
||||
fn is_image_url(s: &str) -> bool {
|
||||
let s = s.to_lowercase();
|
||||
s.ends_with(".png")
|
||||
|| s.ends_with(".jpg")
|
||||
|| s.ends_with(".jpeg")
|
||||
|| s.ends_with(".gif")
|
||||
|| s.ends_with(".svg")
|
||||
|| s.ends_with(".webp")
|
||||
}
|
||||
|
||||
fn format_value(v: &str) -> String {
|
||||
let v = v.trim().to_string();
|
||||
if is_url(&v) {
|
||||
if is_image_url(&v) {
|
||||
format!("[{}]({})<br>", v, v, v)
|
||||
} else {
|
||||
format!("[{}]({})", v, v)
|
||||
}
|
||||
} else {
|
||||
format!("`{}`", v)
|
||||
}
|
||||
}
|
||||
|
||||
fn recurse(elem: &xmltree::Element, md: &mut String, depth: usize) {
|
||||
let indent = " ".repeat(depth);
|
||||
md.push_str(&format!("{}- **{}**", indent, elem.name));
|
||||
|
||||
if !elem.attributes.is_empty() {
|
||||
let attrs: Vec<String> = elem
|
||||
.attributes
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, format_value(v)))
|
||||
.collect();
|
||||
md.push_str(&format!(" ({})", attrs.join(", ")));
|
||||
}
|
||||
|
||||
if let Some(text) = elem
|
||||
.get_text()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
md.push_str(&format!(": {}", format_value(&text)));
|
||||
}
|
||||
|
||||
md.push('\n');
|
||||
|
||||
for child in &elem.children {
|
||||
if let xmltree::XMLNode::Element(child_elem) = child {
|
||||
recurse(child_elem, md, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.push_str("# UPnP XML (Markdown view)\n\n");
|
||||
recurse(&elem, &mut md, 0);
|
||||
md
|
||||
}
|
||||
|
||||
/// Indique si l'objet est une instance.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `false` par défaut. Surchargé par [`UpnpInstance`] pour retourner `true`.
|
||||
fn is_instance(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Indique si l'objet est une collection (set).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `false` par défaut. Surchargé par [`UpnpSet`] pour retourner `true`.
|
||||
fn is_set(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait pour les modèles UPnP qui peuvent créer des instances.
|
||||
///
|
||||
/// Un modèle représente la définition ou template d'un objet UPnP, tandis qu'une
|
||||
/// instance est une occurrence concrète de cet objet.
|
||||
///
|
||||
/// # Type associé
|
||||
///
|
||||
/// - [`Instance`](Self::Instance) : Le type d'instance créée par ce modèle
|
||||
///
|
||||
/// # Méthodes
|
||||
///
|
||||
/// - [`create_instance`](Self::create_instance) : Crée une nouvelle instance
|
||||
///
|
||||
/// # Hiérarchie
|
||||
///
|
||||
/// ```text
|
||||
/// UpnpObject
|
||||
/// └─> UpnpModel
|
||||
/// ```
|
||||
///
|
||||
/// # Relation avec UpnpInstance
|
||||
///
|
||||
/// `UpnpModel` et [`UpnpInstance`] sont liés via leurs types associés :
|
||||
/// - Le modèle spécifie quel type d'instance il crée
|
||||
/// - L'instance spécifie de quel type de modèle elle provient
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// struct DeviceModel { /* ... */ }
|
||||
/// struct DeviceInstance { /* ... */ }
|
||||
///
|
||||
/// impl UpnpModel for DeviceModel {
|
||||
/// type Instance = DeviceInstance;
|
||||
/// }
|
||||
///
|
||||
/// impl UpnpInstance for DeviceInstance {
|
||||
/// type Model = DeviceModel;
|
||||
///
|
||||
/// fn new(model: &DeviceModel) -> Self {
|
||||
/// // Création de l'instance depuis le modèle
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // Utilisation
|
||||
/// let model = DeviceModel::new();
|
||||
/// let instance = model.create_instance(); // Arc<DeviceInstance>
|
||||
/// ```
|
||||
pub trait UpnpModel: UpnpObject {
|
||||
/// Le type d'instance créée par ce modèle.
|
||||
type Instance: UpnpInstance<Model = Self>;
|
||||
|
||||
/// Crée une nouvelle instance à partir de ce modèle.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Un `Arc` contenant la nouvelle instance créée.
|
||||
///
|
||||
/// # Implémentation par défaut
|
||||
///
|
||||
/// Par défaut, appelle [`UpnpInstance::new`] avec une référence vers ce modèle
|
||||
/// et encapsule le résultat dans un `Arc`.
|
||||
fn create_instance(&self) -> Arc<Self::Instance> {
|
||||
Arc::new(Self::Instance::new(self))
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait pour les instances UPnP concrètes.
|
||||
///
|
||||
/// Une instance représente une occurrence concrète d'un objet UPnP, créée à partir
|
||||
/// d'un modèle ([`UpnpModel`]).
|
||||
///
|
||||
/// # Type associé
|
||||
///
|
||||
/// - [`Model`](Self::Model) : Le type du modèle dont cette instance dérive
|
||||
///
|
||||
/// # Méthodes requises
|
||||
///
|
||||
/// - [`new`](Self::new) : Constructeur créant l'instance depuis un modèle
|
||||
///
|
||||
/// # Hiérarchie
|
||||
///
|
||||
/// ```text
|
||||
/// UpnpObject
|
||||
/// └─> UpnpInstance
|
||||
/// ```
|
||||
///
|
||||
/// # Relation avec UpnpModel
|
||||
///
|
||||
/// Voir la documentation de [`UpnpModel`] pour comprendre la relation entre
|
||||
/// modèles et instances.
|
||||
pub trait UpnpInstance: UpnpObject {
|
||||
/// Le type du modèle dont cette instance est dérivée.
|
||||
type Model: UpnpModel<Instance = Self>;
|
||||
|
||||
/// Crée une nouvelle instance à partir d'un modèle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `model` - Référence vers le modèle à partir duquel créer l'instance
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Une nouvelle instance initialisée depuis le modèle.
|
||||
fn new(model: &Self::Model) -> Self;
|
||||
|
||||
/// Indique que cet objet est une instance.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Toujours `true` pour les instances.
|
||||
fn is_instance(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait pour les objets UPnP typés.
|
||||
///
|
||||
/// Ajoute les informations de type et de nom aux objets UPnP.
|
||||
///
|
||||
/// # Méthodes requises
|
||||
///
|
||||
/// - [`as_upnp_object_type`](Self::as_upnp_object_type) : Accès au type de l'objet
|
||||
///
|
||||
/// # Méthodes fournies
|
||||
///
|
||||
/// - [`get_name`](Self::get_name) : Récupère le nom de l'objet
|
||||
/// - [`get_object_type`](Self::get_object_type) : Récupère le type de l'objet
|
||||
///
|
||||
/// # Hiérarchie
|
||||
///
|
||||
/// ```text
|
||||
/// UpnpObject
|
||||
/// └─> UpnpTyped
|
||||
/// ```
|
||||
pub trait UpnpTyped: UpnpObject {
|
||||
/// Retourne une référence vers le type de l'objet.
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType;
|
||||
|
||||
/// Retourne le nom de l'objet.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Une référence vers le nom de l'objet.
|
||||
fn get_name(&self) -> &String {
|
||||
&self.as_upnp_object_type().name
|
||||
}
|
||||
|
||||
/// Retourne le type de l'objet sous forme de chaîne.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Une référence vers le type de l'objet (ex: "Device", "Service", etc.).
|
||||
fn get_object_type(&self) -> &String {
|
||||
&self.as_upnp_object_type().object_type
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait marqueur pour les objets UPnP typés.
|
||||
///
|
||||
/// Combine [`UpnpObject`] et [`UpnpTyped`] pour créer un objet avec toutes
|
||||
/// les fonctionnalités de base plus les informations de type.
|
||||
///
|
||||
/// # Hiérarchie
|
||||
///
|
||||
/// ```text
|
||||
/// UpnpObject + UpnpTyped
|
||||
/// └─> UpnpTypedObject
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// C'est un *marker trait* (trait marqueur) sans méthodes supplémentaires.
|
||||
pub trait UpnpTypedObject: UpnpObject + UpnpTyped {}
|
||||
|
||||
/// Trait marqueur pour les instances typées UPnP.
|
||||
///
|
||||
/// Combine [`UpnpTypedObject`] et [`UpnpInstance`] pour représenter une instance
|
||||
/// concrète d'un objet typé avec toutes les fonctionnalités :
|
||||
/// - Sérialisation XML/Markdown (de [`UpnpObject`])
|
||||
/// - Informations de type et nom (de [`UpnpTyped`])
|
||||
/// - Relation avec un modèle (de [`UpnpInstance`])
|
||||
///
|
||||
/// # Hiérarchie
|
||||
///
|
||||
/// ```text
|
||||
/// UpnpTypedObject + UpnpInstance
|
||||
/// └─> UpnpTypedInstance
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Ce trait ajoute la méthode [`get_model`](Self::get_model) pour accéder
|
||||
/// au modèle de l'instance. Les collections d'instances ([`UpnInstanceSet`])
|
||||
/// n'ont pas cette méthode car elles contiennent plusieurs instances.
|
||||
pub trait UpnpTypedInstance: UpnpTypedObject + UpnpInstance
|
||||
where
|
||||
Self::Model: UpnpModel<Instance = Self>
|
||||
{
|
||||
/// Retourne une référence vers le modèle dont cette instance est dérivée.
|
||||
///
|
||||
/// Permet d'accéder aux métadonnées et contraintes définies dans le modèle,
|
||||
/// telles que les plages de valeurs autorisées, les types, les descriptions, etc.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Une référence immuable vers le modèle.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let instance = model.create_instance();
|
||||
///
|
||||
/// // Accéder aux propriétés du modèle depuis l'instance
|
||||
/// let model_ref = instance.get_model();
|
||||
/// println!("Instance du modèle: {}", model_ref.get_name());
|
||||
///
|
||||
/// // Vérifier les contraintes définies dans le modèle
|
||||
/// if let Some(range) = model_ref.get_range() {
|
||||
/// println!("Plage autorisée: {:?}", range);
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Use cases
|
||||
///
|
||||
/// Cette méthode est particulièrement utile pour :
|
||||
/// - Valider des valeurs contre les contraintes du modèle
|
||||
/// - Accéder aux métadonnées sans dupliquer les informations
|
||||
/// - Afficher des informations de type ou de description
|
||||
/// - Implémenter des logiques conditionnelles basées sur le modèle
|
||||
///
|
||||
/// # Différence avec les traits spécifiques
|
||||
///
|
||||
/// Pour les variables d'état, le trait [`UpnpVariable`](crate::state_variables::UpnpVariable)
|
||||
/// fournit également `get_definition()` qui est sémantiquement équivalent
|
||||
/// mais spécifique au domaine des variables.
|
||||
fn get_model(&self) -> &Self::Model;
|
||||
}
|
||||
|
||||
|
||||
/// Trait marqueur pour les collections UPnP.
|
||||
///
|
||||
/// Représente un ensemble (set) d'objets UPnP.
|
||||
///
|
||||
/// # Super-traits requis
|
||||
///
|
||||
/// - [`UpnpObject`] : Fonctionnalités de base (XML, etc.)
|
||||
/// - [`UpnpDeepClone`] : Permet le clonage profond des collections
|
||||
///
|
||||
/// # Implémentation
|
||||
///
|
||||
/// Ce trait surcharge [`UpnpObject::is_set`] pour retourner `true`.
|
||||
///
|
||||
/// # Hiérarchie
|
||||
///
|
||||
/// ```text
|
||||
/// UpnpObject + UpnpDeepClone
|
||||
/// └─> UpnpSet
|
||||
/// ```
|
||||
///
|
||||
/// # Note sur le clonage
|
||||
///
|
||||
/// Les collections UPnP contiennent généralement des `Arc<T>` vers leurs éléments.
|
||||
/// Le trait [`Clone`] (via `UpnpObject`) effectue un clonage shallow des `Arc`,
|
||||
/// tandis que [`UpnpDeepClone`] clone profondément les éléments contenus.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// struct ServiceSet {
|
||||
/// services: HashMap<String, Arc<Service>>,
|
||||
/// }
|
||||
///
|
||||
/// impl Clone for ServiceSet {
|
||||
/// fn clone(&self) -> Self {
|
||||
/// // Clone shallow : partage les Services via Arc
|
||||
/// Self {
|
||||
/// services: self.services.clone()
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// impl UpnpDeepClone for ServiceSet {
|
||||
/// fn deep_clone(&self) -> Self {
|
||||
/// // Clone profond : crée de nouveaux Services
|
||||
/// let deep_services = self.services
|
||||
/// .iter()
|
||||
/// .map(|(k, v)| (k.clone(), Arc::new((**v).clone())))
|
||||
/// .collect();
|
||||
///
|
||||
/// Self {
|
||||
/// services: deep_services
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait UpnpSet: UpnpObject + UpnpDeepClone {
|
||||
/// Indique que cet objet est une collection.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Toujours `true` pour les collections.
|
||||
fn is_set(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait marqueur pour les collections de modèles UPnP.
|
||||
///
|
||||
/// Combine [`UpnpSet`] et [`UpnpModel`] pour représenter une collection
|
||||
/// de modèles qui peut elle-même créer une collection d'instances.
|
||||
///
|
||||
/// # Cas d'usage
|
||||
///
|
||||
/// Ce trait est utilisé quand une collection de modèles doit pouvoir instancier
|
||||
/// une collection d'instances correspondante. Par exemple :
|
||||
/// - Un ensemble de modèles de services d'un device qui crée un ensemble d'instances de services
|
||||
/// - Une liste de modèles d'actions qui instancie une liste d'actions actives
|
||||
/// - Une collection de modèles de variables d'état qui génère une collection d'instances
|
||||
///
|
||||
/// # Hiérarchie
|
||||
///
|
||||
/// ```text
|
||||
/// UpnpSet + UpnpModel
|
||||
/// └─> UpnpModelSet
|
||||
/// ```
|
||||
///
|
||||
/// # Relation avec d'autres traits
|
||||
///
|
||||
/// - [`UpnpSet`] : Fournit les fonctionnalités de collection
|
||||
/// - [`UpnpModel`] : Fournit la capacité de créer des instances
|
||||
/// - [`UpnInstanceSet`] : Représente les collections d'instances (contrepartie)
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// C'est un *marker trait* (trait marqueur) sans méthodes supplémentaires.
|
||||
/// Il est automatiquement implémenté pour tous les types éligibles via une
|
||||
/// blanket implementation.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// /// Collection de modèles de services
|
||||
/// struct ServiceSetModel {
|
||||
/// services: Vec<Arc<ServiceModel>>,
|
||||
/// }
|
||||
///
|
||||
/// /// Collection d'instances de services
|
||||
/// struct ServiceSetInstance {
|
||||
/// model: Arc<ServiceSetModel>,
|
||||
/// service_instances: Vec<Arc<ServiceInstance>>,
|
||||
/// }
|
||||
///
|
||||
/// impl UpnpObject for ServiceSetModel { /* ... */ }
|
||||
/// impl UpnpSet for ServiceSetModel {}
|
||||
///
|
||||
/// impl UpnpModel for ServiceSetModel {
|
||||
/// type Instance = ServiceSetInstance;
|
||||
///
|
||||
/// fn create_instance(&self) -> Arc<ServiceSetInstance> {
|
||||
/// // Créer des instances pour chaque service
|
||||
/// let instances = self.services
|
||||
/// .iter()
|
||||
/// .map(|model| model.create_instance())
|
||||
/// .collect();
|
||||
///
|
||||
/// Arc::new(ServiceSetInstance {
|
||||
/// model: Arc::new(self.clone()),
|
||||
/// service_instances: instances,
|
||||
/// })
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // UpnpModelSet est automatiquement implémenté !
|
||||
///
|
||||
/// // Utilisation
|
||||
/// let model_set = ServiceSetModel::new();
|
||||
/// let instance_set = model_set.create_instance(); // Crée toutes les instances
|
||||
/// ```
|
||||
pub trait UpnpModelSet: UpnpSet + UpnpModel {}
|
||||
|
||||
|
||||
/// Trait marqueur pour les collections d'instances UPnP.
|
||||
///
|
||||
/// Combine [`UpnpSet`] et [`UpnpInstance`] pour représenter une collection
|
||||
/// d'instances UPnP. Cela permet d'avoir des collections qui sont elles-mêmes
|
||||
/// des instances créées depuis un modèle.
|
||||
///
|
||||
/// # Hiérarchie
|
||||
///
|
||||
/// ```text
|
||||
/// UpnpSet + UpnpInstance
|
||||
/// └─> UpnInstanceSet
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// C'est un *marker trait* (trait marqueur) sans méthodes supplémentaires.
|
||||
pub trait UpnInstanceSet: UpnpSet + UpnpInstance {}
|
||||
|
||||
|
||||
/// Implémentation automatique de [`UpnInstanceSet`] pour tous les types éligibles.
|
||||
///
|
||||
/// Cette *blanket implementation* fournit automatiquement le trait [`UpnInstanceSet`]
|
||||
/// à tout type `T` qui implémente à la fois [`UpnpSet`] et [`UpnpInstance`].
|
||||
///
|
||||
/// # Contraintes
|
||||
///
|
||||
/// - `T` doit implémenter [`UpnpSet`] (collection d'objets UPnP)
|
||||
/// - `T` doit implémenter [`UpnpInstance`] (instance créée depuis un modèle)
|
||||
///
|
||||
/// # Pourquoi cette implémentation existe
|
||||
///
|
||||
/// Certaines collections UPnP sont elles-mêmes des instances (par exemple, une
|
||||
/// collection de services pour un device spécifique). Ce trait marker permet
|
||||
/// d'identifier ces collections qui combinent les deux aspects. La blanket
|
||||
/// implementation évite d'avoir à l'implémenter manuellement pour chaque type.
|
||||
///
|
||||
/// # Utilisation
|
||||
///
|
||||
/// ```ignore
|
||||
/// struct ServiceSetInstance {
|
||||
/// model: Arc<ServiceSetModel>,
|
||||
/// services: Vec<Arc<ServiceInstance>>,
|
||||
/// }
|
||||
///
|
||||
/// impl UpnpObject for ServiceSetInstance { /* ... */ }
|
||||
/// impl UpnpSet for ServiceSetInstance {}
|
||||
/// impl UpnpInstance for ServiceSetInstance {
|
||||
/// type Model = ServiceSetModel;
|
||||
/// fn new(model: &ServiceSetModel) -> Self { /* ... */ }
|
||||
/// }
|
||||
///
|
||||
/// // UpnInstanceSet est automatiquement implémenté !
|
||||
///
|
||||
/// fn process_instance_set<T: UpnInstanceSet>(set: &T) {
|
||||
/// if set.is_set() && set.is_instance() {
|
||||
/// println!("C'est une collection ET une instance");
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
impl<T> UpnInstanceSet for T
|
||||
where
|
||||
T: UpnpSet + UpnpInstance
|
||||
{}
|
||||
|
||||
/// Implémentation automatique de [`UpnpTypedObject`] pour tous les types éligibles.
|
||||
///
|
||||
/// Cette *blanket implementation* fournit automatiquement le trait [`UpnpTypedObject`]
|
||||
/// à tout type `T` qui implémente à la fois [`UpnpObject`] et [`UpnpTyped`].
|
||||
///
|
||||
/// # Contraintes
|
||||
///
|
||||
/// - `T` doit implémenter [`UpnpObject`] (fonctionnalités de base UPnP)
|
||||
/// - `T` doit implémenter [`UpnpTyped`] (informations de type et nom)
|
||||
///
|
||||
/// # Utilisation
|
||||
///
|
||||
/// ```ignore
|
||||
/// struct Device {
|
||||
/// object_type: UpnpObjectType,
|
||||
/// }
|
||||
///
|
||||
/// impl UpnpObject for Device { /* ... */ }
|
||||
/// impl UpnpTyped for Device { /* ... */ }
|
||||
///
|
||||
/// // UpnpTypedObject est automatiquement implémenté !
|
||||
/// fn process<T: UpnpTypedObject>(obj: &T) {
|
||||
/// println!("{}", obj.get_name());
|
||||
/// }
|
||||
/// ```
|
||||
impl<T> UpnpTypedObject for T
|
||||
where
|
||||
T: UpnpObject + UpnpTyped
|
||||
{}
|
||||
|
||||
|
||||
/// Implémentation automatique de [`UpnpModelSet`] pour tous les types éligibles.
|
||||
///
|
||||
/// Cette *blanket implementation* fournit automatiquement le trait [`UpnpModelSet`]
|
||||
/// à tout type `T` qui implémente à la fois [`UpnpSet`] et [`UpnpModel`].
|
||||
///
|
||||
/// # Contraintes
|
||||
///
|
||||
/// - `T` doit implémenter [`UpnpSet`] (collection d'objets UPnP)
|
||||
/// - `T` doit implémenter [`UpnpModel`] (peut créer des instances)
|
||||
///
|
||||
/// # Pourquoi cette implémentation existe
|
||||
///
|
||||
/// [`UpnpModelSet`] est un *marker trait* qui identifie les collections pouvant
|
||||
/// créer des collections d'instances. Plutôt que de demander aux développeurs
|
||||
/// d'écrire manuellement `impl UpnpModelSet for MyType {}`, cette blanket
|
||||
/// implementation le fait automatiquement dès que les traits requis sont implémentés.
|
||||
///
|
||||
/// # Fonctionnement
|
||||
///
|
||||
/// Lorsque vous définissez une collection de modèles :
|
||||
///
|
||||
/// ```ignore
|
||||
/// struct ActionSetModel {
|
||||
/// actions: Vec<Arc<ActionModel>>,
|
||||
/// }
|
||||
///
|
||||
/// impl UpnpObject for ActionSetModel { /* ... */ }
|
||||
/// impl UpnpSet for ActionSetModel {}
|
||||
///
|
||||
/// impl UpnpModel for ActionSetModel {
|
||||
/// type Instance = ActionSetInstance;
|
||||
/// fn create_instance(&self) -> Arc<ActionSetInstance> { /* ... */ }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Le compilateur Rust vérifie automatiquement que `ActionSetModel` satisfait
|
||||
/// toutes les contraintes (implémente `UpnpSet` ET `UpnpModel`) et applique
|
||||
/// donc `UpnpModelSet` sans code supplémentaire.
|
||||
///
|
||||
/// # Utilisation dans des signatures génériques
|
||||
///
|
||||
/// ```ignore
|
||||
/// fn process_model_set<T: UpnpModelSet>(set: &T) {
|
||||
/// println!("Processing model set that can create instances");
|
||||
/// let instance = set.create_instance();
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Différence avec UpnInstanceSet
|
||||
///
|
||||
/// - [`UpnpModelSet`] : Collection de **modèles** (peut créer des instances)
|
||||
/// - [`UpnInstanceSet`] : Collection d'**instances** (créée depuis un modèle)
|
||||
impl<T> UpnpModelSet for T
|
||||
where
|
||||
T: UpnpSet + UpnpModel
|
||||
{}
|
||||
|
||||
|
||||
159
pmoupnp/src/server/logs/mod.rs
Normal file
159
pmoupnp/src/server/logs/mod.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
// logs.rs
|
||||
mod sselayer;
|
||||
|
||||
pub use sselayer::SseLayer;
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::{Arc, RwLock},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
response::{
|
||||
IntoResponse,
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Représente une entrée de log
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: SystemTime,
|
||||
pub level: String,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Buffer circulaire partagé
|
||||
#[derive(Clone)]
|
||||
pub struct LogState {
|
||||
buffer: Arc<RwLock<VecDeque<LogEntry>>>,
|
||||
tx: broadcast::Sender<LogEntry>,
|
||||
}
|
||||
|
||||
impl LogState {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(capacity))),
|
||||
tx: broadcast::channel(1000).0,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&self, entry: LogEntry) {
|
||||
let mut buf = self.buffer.write().unwrap();
|
||||
if buf.len() == buf.capacity() {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(entry.clone());
|
||||
let _ = self.tx.send(entry);
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<LogEntry> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn dump(&self) -> Vec<LogEntry> {
|
||||
self.buffer.read().unwrap().iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Query params pour /log-sse
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LogQuery {
|
||||
#[serde(default)]
|
||||
pub error: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub warn: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub info: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub debug: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub trace: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
/// Handler SSE
|
||||
// Dans logs.rs
|
||||
pub async fn log_sse(
|
||||
State(state): State<LogState>,
|
||||
Query(params): Query<LogQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let mut rx = state.subscribe();
|
||||
|
||||
// Récupérer l'historique du buffer
|
||||
let history = state.dump();
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
// 1. Envoyer d'abord tous les logs historiques
|
||||
for entry in history {
|
||||
if !filter_entry(&entry, ¶ms) {
|
||||
continue;
|
||||
}
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
yield Ok::<_, axum::Error>(Event::default().data(json));
|
||||
}
|
||||
|
||||
// 2. Puis streamer les nouveaux logs en temps réel
|
||||
while let Ok(entry) = rx.recv().await {
|
||||
if !filter_entry(&entry, ¶ms) {
|
||||
continue;
|
||||
}
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
yield Ok::<_, axum::Error>(Event::default().data(json));
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// Handler REST (dump JSON du buffer)
|
||||
pub async fn log_dump(State(state): State<LogState>) -> impl IntoResponse {
|
||||
Json(state.dump())
|
||||
}
|
||||
|
||||
/// Fonction de filtrage
|
||||
fn filter_entry(entry: &LogEntry, q: &LogQuery) -> bool {
|
||||
// Filtrage par niveau
|
||||
let lvl = entry.level.to_lowercase();
|
||||
let mut allowed = false;
|
||||
|
||||
if let Some(true) = q.error {
|
||||
allowed |= lvl == "error";
|
||||
}
|
||||
if let Some(true) = q.warn {
|
||||
allowed |= lvl == "warn";
|
||||
}
|
||||
if let Some(true) = q.info {
|
||||
allowed |= lvl == "info";
|
||||
}
|
||||
if let Some(true) = q.debug {
|
||||
allowed |= lvl == "debug";
|
||||
}
|
||||
if let Some(true) = q.trace {
|
||||
allowed |= lvl == "trace";
|
||||
}
|
||||
|
||||
// si aucun flag → tout est autorisé
|
||||
if !(q.error.unwrap_or(false)
|
||||
|| q.warn.unwrap_or(false)
|
||||
|| q.info.unwrap_or(false)
|
||||
|| q.debug.unwrap_or(false)
|
||||
|| q.trace.unwrap_or(false))
|
||||
{
|
||||
allowed = true;
|
||||
}
|
||||
|
||||
// Filtrage par mot-clé
|
||||
if let Some(search) = &q.search {
|
||||
allowed &= entry.message.contains(search) || entry.target.contains(search);
|
||||
}
|
||||
|
||||
allowed
|
||||
}
|
||||
63
pmoupnp/src/server/logs/sselayer.rs
Normal file
63
pmoupnp/src/server/logs/sselayer.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing::{Event, Subscriber};
|
||||
use tracing_subscriber::{Layer, layer::Context};
|
||||
|
||||
use super::{LogEntry, LogState};
|
||||
use std::time::SystemTime;
|
||||
|
||||
struct LogVisitor {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl LogVisitor {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visit for LogVisitor {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
// capture le champ "message" ou concatène les autres
|
||||
if field.name() == "message" {
|
||||
self.message = format!("{:?}", value);
|
||||
} else {
|
||||
if !self.message.is_empty() {
|
||||
self.message.push(' ');
|
||||
}
|
||||
self.message
|
||||
.push_str(&format!("{}={:?}", field.name(), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer de tracing qui pousse les events dans le buffer
|
||||
pub struct SseLayer {
|
||||
state: LogState,
|
||||
}
|
||||
|
||||
impl SseLayer {
|
||||
pub fn new(state: LogState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for SseLayer
|
||||
where
|
||||
S: Subscriber,
|
||||
{
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
let mut visitor = LogVisitor::new();
|
||||
event.record(&mut visitor);
|
||||
|
||||
let entry = LogEntry {
|
||||
timestamp: SystemTime::now(),
|
||||
level: event.metadata().level().to_string(),
|
||||
target: event.metadata().target().to_string(),
|
||||
message: visitor.message,
|
||||
};
|
||||
|
||||
self.state.push(entry);
|
||||
}
|
||||
}
|
||||
566
pmoupnp/src/server/mod.rs
Normal file
566
pmoupnp/src/server/mod.rs
Normal file
@@ -0,0 +1,566 @@
|
||||
//! # Module Server - API de haut niveau pour Axum
|
||||
//!
|
||||
//! Ce module fournit une abstraction simple et ergonomique pour créer des serveurs HTTP
|
||||
//! avec Axum, en cachant la complexité de la configuration et du routage.
|
||||
//!
|
||||
//! ## Fonctionnalités
|
||||
//!
|
||||
//! - 🚀 **Routes JSON simples** : Ajoutez des endpoints API avec `add_route()`
|
||||
//! - 📁 **Fichiers statiques** : Servez des assets avec `add_dir()`
|
||||
//! - ⚛️ **Applications SPA** : Support pour Vue.js/React avec `add_spa()`
|
||||
//! - 🔀 **Redirections** : Redirigez des routes avec `add_redirect()`
|
||||
//! - 🎯 **Handlers personnalisés** : Support SSE, WebSocket, etc. avec `add_handler_with_state()`
|
||||
//! - 📚 **Documentation API** : OpenAPI/Swagger automatique avec `add_openapi()`
|
||||
//! - ⚡ **Gestion gracieuse** : Arrêt propre sur Ctrl+C
|
||||
|
||||
pub mod logs;
|
||||
|
||||
use axum::handler::Handler;
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use axum_embed::ServeEmbed;
|
||||
use pmoconfig::get_config;
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::Serialize;
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use tokio::{signal, sync::RwLock, task::JoinHandle};
|
||||
use tracing::{info, warn, debug, error};
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
/// Info serveur sérialisable
|
||||
#[derive(Clone, Serialize, utoipa::ToSchema)]
|
||||
pub struct ServerInfo {
|
||||
/// Nom du serveur
|
||||
pub name: String,
|
||||
/// URL de base
|
||||
pub base_url: String,
|
||||
/// Port HTTP
|
||||
pub http_port: u16,
|
||||
}
|
||||
|
||||
/// Serveur principal
|
||||
pub struct Server {
|
||||
name: String,
|
||||
base_url: String,
|
||||
http_port: u16,
|
||||
router: Arc<RwLock<Router>>,
|
||||
api_router: Arc<RwLock<Option<Router>>>,
|
||||
join_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[derive(RustEmbed, Clone)]
|
||||
#[folder = "webapp/dist"]
|
||||
pub struct Webapp;
|
||||
|
||||
impl Server {
|
||||
/// Crée une nouvelle instance de serveur
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - Nom du serveur (pour les logs)
|
||||
/// * `base_url` - URL de base (ex: "http://localhost:3000")
|
||||
/// * `http_port` - Port HTTP à écouter
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// let server = Server::new("MyAPI", "http://localhost:3000", 3000);
|
||||
/// ```
|
||||
pub fn new(name: impl Into<String>, base_url: impl Into<String>, http_port: u16) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
base_url: base_url.into(),
|
||||
http_port,
|
||||
router: Arc::new(RwLock::new(Router::new())),
|
||||
api_router: Arc::new(RwLock::new(None)),
|
||||
join_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_configured() -> Self {
|
||||
let config = get_config();
|
||||
let url = config.get_base_url();
|
||||
let port = config.get_http_port();
|
||||
|
||||
return Self::new("PMO-Music-Server", url, port);
|
||||
}
|
||||
|
||||
/// Ajoute une route JSON dynamique
|
||||
///
|
||||
/// Crée un endpoint qui retourne du JSON. La closure fournie sera appelée
|
||||
/// à chaque requête GET sur le chemin spécifié.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin de la route (ex: "/api/hello")
|
||||
/// * `f` - Closure async retournant une valeur sérialisable
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// server.add_route("/api/status", || async {
|
||||
/// serde_json::json!({
|
||||
/// "status": "online",
|
||||
/// "version": "1.0.0"
|
||||
/// })
|
||||
/// }).await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn add_route<F, Fut, T>(&mut self, path: &str, f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: std::future::Future<Output = T> + Send + 'static,
|
||||
T: Serialize + Send + 'static,
|
||||
{
|
||||
let f = Arc::new(f);
|
||||
|
||||
let handler = {
|
||||
let f = f.clone();
|
||||
move || {
|
||||
let f = f.clone();
|
||||
async move { Json(f().await) }
|
||||
}
|
||||
};
|
||||
|
||||
let route = Router::new().route("/", get(handler));
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
|
||||
/// Ajoute un répertoire de fichiers statiques
|
||||
///
|
||||
/// Sert des fichiers embarqués via `RustEmbed`. Les fichiers sont compilés
|
||||
/// dans le binaire à la compilation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin où monter les fichiers statiques
|
||||
///
|
||||
/// # Type Parameter
|
||||
///
|
||||
/// * `E` - Type RustEmbed définissant le répertoire à servir
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmoupnp::server::Server;
|
||||
/// use rust_embed::RustEmbed;
|
||||
///
|
||||
/// #[derive(RustEmbed, Clone)]
|
||||
/// #[folder = "static/"]
|
||||
/// struct Assets;
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// server.add_dir::<Assets>("/assets").await;
|
||||
/// // Les fichiers de static/ sont accessibles via /assets/*
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn add_dir<E>(&mut self, path: &str)
|
||||
where
|
||||
E: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let serve = ServeEmbed::<E>::new();
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
|
||||
if path == "/" {
|
||||
*r = std::mem::take(&mut *r).fallback_service(serve);
|
||||
} else {
|
||||
let route = Router::new().fallback_service(serve);
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute une Single Page Application (SPA)
|
||||
///
|
||||
/// Sert une application JavaScript moderne (Vue.js, React, etc.) avec support
|
||||
/// du routage côté client. Tous les chemins non trouvés renvoient `index.html`
|
||||
/// pour permettre au routeur JavaScript de gérer la navigation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin où monter l'application (souvent "/" ou "/app")
|
||||
///
|
||||
/// # Type Parameter
|
||||
///
|
||||
/// * `E` - Type RustEmbed contenant les fichiers de la SPA
|
||||
///
|
||||
/// # Exemple avec Vue.js
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # use rust_embed::RustEmbed;
|
||||
/// #[derive(RustEmbed, Clone)]
|
||||
/// #[folder = "webapp/dist"] // Build output de Vue.js
|
||||
/// struct WebApp;
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// server.add_spa::<WebApp>("/").await;
|
||||
/// // L'app Vue.js gère toutes les routes comme /about, /users, etc.
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Pour Vue.js/Vite, configure le `base` dans `vite.config.js` si tu montes
|
||||
/// sur un sous-chemin :
|
||||
/// ```javascript
|
||||
/// export default {
|
||||
/// base: '/app/'
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn add_spa<E>(&mut self, path: &str)
|
||||
where
|
||||
E: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let serve = ServeEmbed::<E>::with_parameters(
|
||||
Some("index.html".to_string()),
|
||||
axum_embed::FallbackBehavior::Ok,
|
||||
Some("index.html".to_string()),
|
||||
);
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
|
||||
if path == "/" {
|
||||
*r = std::mem::take(&mut *r).fallback_service(serve);
|
||||
} else {
|
||||
let route = Router::new().fallback_service(serve);
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute un handler Axum personnalisé
|
||||
///
|
||||
/// Pour des cas d'usage avancés nécessitant un contrôle complet sur le handler.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin de la route
|
||||
/// * `handler` - Handler Axum
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # use axum::response::Html;
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// async fn custom_handler() -> Html<&'static str> {
|
||||
/// Html("<h1>Custom Response</h1>")
|
||||
/// }
|
||||
///
|
||||
/// server.add_handler("/custom", custom_handler).await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn add_handler<H, T>(&mut self, path: &str, handler: H)
|
||||
where
|
||||
H: Handler<T, ()>,
|
||||
T: 'static,
|
||||
{
|
||||
let route = Router::new().route("/", get(handler));
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
|
||||
/// Ajoute un handler avec state (pour SSE, extracteurs, etc.)
|
||||
///
|
||||
/// Permet d'utiliser des extracteurs Axum comme `State`, `Query`, etc.
|
||||
/// Idéal pour Server-Sent Events (SSE), WebSockets ou tout handler nécessitant un état partagé.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin de la route
|
||||
/// * `handler` - Handler Axum avec extracteurs
|
||||
/// * `state` - État partagé (doit être Clone + Send + Sync)
|
||||
///
|
||||
/// # Exemple avec SSE
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmoupnp::server::Server;
|
||||
/// use axum::extract::State;
|
||||
/// use axum::response::sse::{Event, Sse, KeepAlive};
|
||||
/// use tokio::sync::broadcast;
|
||||
///
|
||||
/// #[derive(Clone)]
|
||||
/// struct LogState {
|
||||
/// tx: broadcast::Sender<String>
|
||||
/// }
|
||||
///
|
||||
/// impl LogState {
|
||||
/// fn subscribe(&self) -> broadcast::Receiver<String> {
|
||||
/// self.tx.subscribe()
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// async fn log_sse(State(state): State<LogState>) -> Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
/// let mut rx = state.subscribe();
|
||||
/// let stream = async_stream::stream! {
|
||||
/// while let Ok(msg) = rx.recv().await {
|
||||
/// yield Ok(Event::default().data(msg));
|
||||
/// }
|
||||
/// };
|
||||
/// Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
/// }
|
||||
///
|
||||
/// let log_state = LogState { tx: broadcast::channel(100).0 };
|
||||
/// server.add_handler_with_state("/logs", log_sse, log_state).await;
|
||||
/// ```
|
||||
pub async fn add_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
||||
where
|
||||
H: Handler<T, S>,
|
||||
T: 'static,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let route = Router::new()
|
||||
.route("/", get(handler))
|
||||
.with_state(state);
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
|
||||
/// Ajoute un handler POST avec state
|
||||
///
|
||||
/// Similaire à `add_handler_with_state` mais pour les requêtes POST.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin de la route
|
||||
/// * `handler` - Handler Axum pour POST
|
||||
/// * `state` - État partagé
|
||||
pub async fn add_post_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
||||
where
|
||||
H: Handler<T, S>,
|
||||
T: 'static,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let route = Router::new()
|
||||
.route("/", axum::routing::post(handler))
|
||||
.with_state(state);
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
|
||||
/// Ajoute une redirection HTTP
|
||||
///
|
||||
/// Redirige automatiquement les requêtes d'un chemin vers un autre avec un code 308 (permanent).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `from` - Chemin source (peut être "/" pour la racine)
|
||||
/// * `to` - Chemin de destination
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// // Rediriger la racine vers /app
|
||||
/// server.add_redirect("/", "/app").await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn add_redirect(&mut self, from: &str, to: &str) {
|
||||
let to = to.to_string();
|
||||
let handler = move || {
|
||||
let to = to.clone();
|
||||
async move { Redirect::permanent(&to) }
|
||||
};
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
|
||||
if from == "/" {
|
||||
// Pour la racine, utiliser merge au lieu de nest
|
||||
let route = Router::new().route("/", get(handler));
|
||||
*r = std::mem::take(&mut *r).merge(route);
|
||||
} else {
|
||||
let route = Router::new().route("/", get(handler));
|
||||
*r = std::mem::take(&mut *r).nest(from, route);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute une API documentée avec OpenAPI
|
||||
///
|
||||
/// Monte un routeur d'API sous `/api` et active Swagger UI sur `/swagger-ui`
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `api_router` - Router Axum contenant les routes API
|
||||
/// * `openapi` - Spécification OpenAPI générée par utoipa
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```ignore
|
||||
/// use utoipa::OpenApi;
|
||||
/// use axum::{Router, Json, routing::get};
|
||||
/// use serde::{Serialize, Deserialize};
|
||||
///
|
||||
/// #[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
/// struct User {
|
||||
/// id: u64,
|
||||
/// name: String,
|
||||
/// }
|
||||
///
|
||||
/// #[derive(utoipa::OpenApi)]
|
||||
/// #[openapi(
|
||||
/// paths(get_users),
|
||||
/// components(schemas(User))
|
||||
/// )]
|
||||
/// struct ApiDoc;
|
||||
///
|
||||
/// #[utoipa::path(
|
||||
/// get,
|
||||
/// path = "/users",
|
||||
/// responses((status = 200, description = "List users"))
|
||||
/// )]
|
||||
/// async fn get_users() -> Json<Vec<User>> {
|
||||
/// Json(vec![])
|
||||
/// }
|
||||
///
|
||||
/// let api_router = Router::new()
|
||||
/// .route("/users", get(get_users));
|
||||
///
|
||||
/// server.add_openapi(api_router, ApiDoc::openapi()).await;
|
||||
/// ```
|
||||
pub async fn add_openapi(&mut self, api_router: Router, openapi: utoipa::openapi::OpenApi) {
|
||||
// Stocker le routeur API
|
||||
let mut api_r = self.api_router.write().await;
|
||||
*api_r = Some(api_router);
|
||||
|
||||
// Ajouter Swagger UI
|
||||
let swagger = SwaggerUi::new("/swagger-ui")
|
||||
.url("/api-docs/openapi.json", openapi);
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).merge(swagger);
|
||||
}
|
||||
|
||||
/// Démarre le serveur HTTP
|
||||
///
|
||||
/// Lance le serveur sur le port configuré et met en place la gestion
|
||||
/// de Ctrl+C pour un arrêt gracieux.
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// server.start().await;
|
||||
/// server.wait().await; // Attend Ctrl+C
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn start(&mut self) {
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], self.http_port));
|
||||
info!("Server {} running at [http://{}:{}](http://{}:{})", self.name, self.base_url, self.http_port, self.base_url, self.http_port);
|
||||
|
||||
// Merger le routeur API si présent
|
||||
let api_router = self.api_router.read().await;
|
||||
if let Some(api_r) = api_router.as_ref() {
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).nest("/api", api_r.clone());
|
||||
}
|
||||
drop(api_router);
|
||||
|
||||
let router = self.router.clone();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let r = router.read().await.clone();
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, r.into_make_service()).await.unwrap();
|
||||
});
|
||||
|
||||
let shutdown_task = tokio::spawn(async move {
|
||||
signal::ctrl_c().await.expect("failed to listen for ctrl_c");
|
||||
info!("Ctrl+C reçu, arrêt gracieux");
|
||||
});
|
||||
|
||||
self.join_handle = Some(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = server_task => {},
|
||||
_ = shutdown_task => {},
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Attend la fin du serveur
|
||||
pub async fn wait(&mut self) {
|
||||
if let Some(h) = self.join_handle.take() {
|
||||
let _ = h.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les infos du serveur
|
||||
pub fn info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
name: self.name.clone(),
|
||||
base_url: self.base_url.clone(),
|
||||
http_port: self.http_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder pattern
|
||||
pub struct ServerBuilder {
|
||||
name: String,
|
||||
base_url: String,
|
||||
http_port: u16,
|
||||
}
|
||||
|
||||
impl ServerBuilder {
|
||||
/// Crée un nouveau builder
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - Nom du serveur
|
||||
/// * `base_url` - URL de base (ex: "http://localhost:3000")
|
||||
/// * `http_port` - Port HTTP
|
||||
pub fn new(name: impl Into<String>, base_url: impl Into<String>, http_port: u16) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
base_url: base_url.into(),
|
||||
http_port,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_configured() -> Self {
|
||||
let config = get_config();
|
||||
Self {
|
||||
name: "PMO-Music-Server".to_string(),
|
||||
base_url: config.get_base_url(),
|
||||
http_port: config.get_http_port()
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit le serveur
|
||||
///
|
||||
/// Consomme le builder et retourne une instance de `Server` prête à l'emploi.
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pmoupnp::server::ServerBuilder;
|
||||
/// let mut server = ServerBuilder::new("MyAPI", "http://localhost:3000", 3000)
|
||||
/// .build();
|
||||
/// ```
|
||||
pub fn build(self) -> Server {
|
||||
Server::new(self.name, self.base_url, self.http_port)
|
||||
}
|
||||
}
|
||||
62
pmoupnp/src/services/errors.rs
Normal file
62
pmoupnp/src/services/errors.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
//! Erreurs du module services.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Erreurs liées aux services UPnP.
|
||||
///
|
||||
/// Cette énumération couvre toutes les erreurs possibles lors de la manipulation
|
||||
/// de services UPnP, incluant les erreurs de validation, de configuration et d'exécution.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ServiceError {
|
||||
/// Erreur générale du service.
|
||||
#[error("Service error: {0}")]
|
||||
GeneralError(String),
|
||||
|
||||
/// Erreur de validation (paramètres invalides).
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
/// Erreur lors d'une opération sur un ensemble (Set).
|
||||
#[error("Set operation error: {0}")]
|
||||
SetError(String),
|
||||
|
||||
/// Erreur liée à une action.
|
||||
#[error("Action error: {0}")]
|
||||
ActionError(String),
|
||||
|
||||
/// Erreur liée à une variable d'état.
|
||||
#[error("State variable error: {0}")]
|
||||
StateVariableError(String),
|
||||
|
||||
/// Erreur de configuration.
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
/// Erreur réseau ou HTTP.
|
||||
#[error("Network error: {0}")]
|
||||
NetworkError(String),
|
||||
|
||||
/// Erreur de sérialisation XML.
|
||||
#[error("XML serialization error: {0}")]
|
||||
XmlError(String),
|
||||
|
||||
/// Erreur lors du traitement SOAP.
|
||||
#[error("SOAP error: {0}")]
|
||||
SoapError(String),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ServiceError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
ServiceError::GeneralError(format!("IO error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::UpnpObjectSetError> for ServiceError {
|
||||
fn from(err: crate::UpnpObjectSetError) -> Self {
|
||||
match err {
|
||||
crate::UpnpObjectSetError::AlreadyExists(name) => {
|
||||
ServiceError::SetError(format!("Object already exists: {}", name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user