Ajout du rendering control

This commit is contained in:
2025-11-30 09:26:33 +01:00
parent bed0eca7f6
commit cf2636a992
10 changed files with 1064 additions and 76 deletions

View File

@@ -0,0 +1,229 @@
// pmocontrol/examples/renderer_demo.rs
use anyhow::{anyhow, Result};
use pmocontrol::{ControlPoint, Renderer};
use std::env;
use std::io::{self, Write};
use std::thread;
use std::time::Duration;
fn main() -> Result<()> {
// -------------------------------------------------------------------------
// CLI arguments
//
// Usage:
// renderer_demo
// -> URI par défaut, renderer index 0
//
// renderer_demo 1
// -> URI par défaut, renderer index 1
//
// renderer_demo https://.../track.flac
// -> cette URI, renderer index 0
//
// renderer_demo https://.../track.flac 1
// -> cette URI, renderer index 1
// -------------------------------------------------------------------------
let default_uri =
"https://audio-fb.radioparadise.com/chan/1/x/1117/4/g/1117-3.flac".to_string();
let mut uri = default_uri.clone();
let mut renderer_index: usize = 0;
let args: Vec<String> = env::args().collect();
if args.len() >= 2 {
let first = &args[1];
if let Ok(idx) = first.parse::<usize>() {
// cas: renderer_demo 1 [URI]
renderer_index = idx;
if args.len() >= 3 {
uri = args[2].clone();
}
} else {
// cas: renderer_demo URI [INDEX]
uri = first.clone();
if args.len() >= 3 {
if let Ok(idx) = args[2].parse::<usize>() {
renderer_index = idx;
}
}
}
}
println!("Using URI : {}", uri);
println!("Requested renderer index : {}", renderer_index);
// -------------------------------------------------------------------------
// 1. Démarrer le control point et laisser la découverte tourner un peu
// -------------------------------------------------------------------------
let cp = ControlPoint::spawn(5)?;
thread::sleep(Duration::from_secs(5));
// -------------------------------------------------------------------------
// 2. Récupérer la liste des renderers (handles haut niveau)
// -------------------------------------------------------------------------
let mut renderers: Vec<Renderer> = cp.list_renderer_handles();
// Filtre : on ignore le renderer PMOMusic interne en développement
renderers.retain(|r| {
!r.friendly_name()
.to_ascii_lowercase()
.contains("pmomusic audio renderer")
});
if renderers.is_empty() {
println!("No valid UPnP MediaRenderer discovered.");
return Ok(());
}
println!("\nDiscovered MediaRenderers:");
for (idx, r) in renderers.iter().enumerate() {
println!(
" [{}] {} | model={} | manufacturer={} | has_avt={} | has_rc={}",
idx,
r.friendly_name(),
r.info.model_name,
r.info.manufacturer,
r.has_avtransport(),
r.has_rendering_control(),
);
}
if renderer_index >= renderers.len() {
return Err(anyhow!(
"Renderer index {} out of range (0..={})",
renderer_index,
renderers.len().saturating_sub(1)
));
}
// -------------------------------------------------------------------------
// 3. Sélection du renderer
// -------------------------------------------------------------------------
let renderer = &renderers[renderer_index];
println!("\nSelected renderer (index {}):", renderer_index);
println!(" Name : {}", renderer.friendly_name());
println!(" Model : {}", renderer.info.model_name);
println!(" Manufacturer: {}", renderer.info.manufacturer);
println!(" UDN : {}", renderer.info.udn);
println!(" Location : {}", renderer.info.location);
println!(" has_avt : {}", renderer.has_avtransport());
println!(" has_rc : {}", renderer.has_rendering_control());
// -------------------------------------------------------------------------
// 4. Si RenderingControl dispo : tester get/set volume + mute
// (set_* remet juste la même valeur pour ne rien changer en pratique)
// -------------------------------------------------------------------------
if renderer.has_rendering_control() {
println!("\n[RenderingControl]");
match renderer.get_master_volume() {
Ok(vol) => {
println!(" Current master volume: {}", vol);
if let Err(e) = renderer.set_master_volume(vol) {
println!(" set_master_volume({}) failed: {}", vol, e);
} else {
println!(" set_master_volume({}) OK (no-op)", vol);
}
}
Err(e) => {
println!(" get_master_volume() failed: {}", e);
}
}
match renderer.get_master_mute() {
Ok(muted) => {
println!(" Current master mute : {}", muted);
if let Err(e) = renderer.set_master_mute(muted) {
println!(" set_master_mute({}) failed: {}", muted, e);
} else {
println!(" set_master_mute({}) OK (no-op)", muted);
}
}
Err(e) => {
println!(" get_master_mute() failed: {}", e);
}
}
} else {
println!("\n[RenderingControl]");
println!(" Renderer has no RenderingControl service.");
}
// -------------------------------------------------------------------------
// 5. Si AVTransport dispo : tester play_uri / seek / pause / stop
// -------------------------------------------------------------------------
if !renderer.has_avtransport() {
println!("\n[AVTransport]");
println!(" Renderer has no AVTransport service, skipping playback tests.");
return Ok(());
}
println!("\n[AVTransport]");
// Helper pour afficher l'état courant
let dump_info = |label: &str| -> Result<()> {
let info = renderer
.avtransport()
.and_then(|_| renderer.avtransport().unwrap().get_transport_info(0))?;
println!("\n [{}]", label);
println!(" State : {}", info.current_transport_state);
println!(" Status : {}", info.current_transport_status);
println!(" Speed : {}", info.current_speed);
Ok(())
};
// Set + Play
println!("\n Calling play_uri(...)");
renderer.play_uri(&uri, "")?;
println!(" play_uri: OK");
thread::sleep(Duration::from_secs(8));
let _ = dump_info("After play_uri");
// Seek (si supporté)
println!("\n Calling seek_rel_time(\"00:01:00\")...");
match renderer.seek_rel_time("00:01:00") {
Ok(()) => {
println!(" seek_rel_time: OK");
thread::sleep(Duration::from_secs(3));
let _ = dump_info("After seek_rel_time");
}
Err(e) => {
println!(" seek_rel_time failed: {}", e);
}
}
// Pause (ENTER pour laisser jouer)
print!("\nPress ENTER to Pause...");
io::stdout().flush().ok();
let _ = io::stdin().read_line(&mut String::new());
println!("\n Calling pause()...");
match renderer.pause() {
Ok(()) => {
println!(" pause: OK");
thread::sleep(Duration::from_secs(2));
let _ = dump_info("After pause");
}
Err(e) => {
println!(" pause failed: {}", e);
}
}
// Stop
print!("\nPress ENTER to Stop...");
io::stdout().flush().ok();
let _ = io::stdin().read_line(&mut String::new());
println!("\n Calling stop()...");
match renderer.stop() {
Ok(()) => {
println!(" stop: OK");
let _ = dump_info("After stop");
}
Err(e) => {
println!(" stop failed: {}", e);
}
}
println!("\nDone.");
Ok(())
}

View File

@@ -1,101 +1,158 @@
use anyhow::{anyhow, Result};
use pmocontrol::{ControlPoint, DeviceRegistryRead, RendererInfo};
use std::env;
use std::io::{self, Write};
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result};
use pmocontrol::{
ControlPoint,
DeviceRegistryRead,
DiscoveredEndpoint,
HttpXmlDescriptionProvider,
};
fn main() -> Result<()> {
// Optionnel mais pratique si tu as déjà tracing dans la stack
tracing_subscriber::fmt::init();
// Default values
let default_uri =
"https://audio-fb.radioparadise.com/chan/1/x/1117/4/g/1117-3.flac".to_string();
let mut uri = default_uri.clone();
let mut renderer_index: usize = 0;
// 1. Créer le control point et lancer la découverte SSDP
//
// timeout_secs = 5 pour les HTTP GET des description.xml
println!("Starting control point (HTTP timeout = 5s)...");
let cp = ControlPoint::spawn(5).context("Failed to spawn ControlPoint")?;
// Args:
// - si args[1] est un entier : index, args[2] éventuel = uri
// - sinon : args[1] = uri, args[2] éventuel = index
let args: Vec<String> = env::args().collect();
if args.len() >= 2 {
let first = &args[1];
if let Ok(idx) = first.parse::<usize>() {
// cas: avtransport_demo 1 [URI]
renderer_index = idx;
if args.len() >= 3 {
uri = args[2].clone();
}
} else {
// cas: avtransport_demo URI [INDEX]
uri = first.clone();
if args.len() >= 3 {
if let Ok(idx) = args[2].parse::<usize>() {
renderer_index = idx;
}
}
}
}
let registry = cp.registry();
println!("Using URI: {}", uri);
println!("Requested renderer index: {}", renderer_index);
// 2. Laisser un peu de temps à la découverte
println!("Waiting 5 seconds for UPnP discovery...");
// 1. Start control point and let discovery run a bit
let cp = ControlPoint::spawn(5)?;
thread::sleep(Duration::from_secs(5));
// 3. Lister les renderers connus
let renderers = {
let reg = registry.read().expect("DeviceRegistry RwLock poisoned");
reg.list_renderers()
};
// 2. Get registry snapshot
let registry = cp.registry();
let reg = registry.read().unwrap();
let all_renderers = reg.list_renderers();
// Filter out the in-dev PMOMusic renderer
let renderers: Vec<RendererInfo> = all_renderers
.into_iter()
.filter(|r| {
!r.friendly_name
.to_ascii_lowercase()
.contains("pmomusic audio renderer")
})
.collect();
if renderers.is_empty() {
println!("No UPnP renderers discovered.");
println!("No valid UPnP MediaRenderer discovered.");
return Ok(());
}
println!("Discovered renderers:");
println!("Discovered MediaRenderers:");
for (idx, r) in renderers.iter().enumerate() {
println!(
" [{}] {} (model: {}, UDN: {})",
idx, r.friendly_name, r.model_name, r.udn
" [{}] {} | model={} | udn={} | location={}",
idx,
r.friendly_name,
r.model_name,
r.udn,
r.location,
);
}
// 4. Choisir un renderer (ici: le premier)
let renderer = renderers[0].clone();
println!(
"\nUsing renderer [0]: {} (model: {}, UDN: {})",
renderer.friendly_name, renderer.model_name, renderer.udn
);
println!("Location: {}", renderer.location);
if renderer_index >= renderers.len() {
return Err(anyhow!(
"Renderer index {} out of range (0..={})",
renderer_index,
renderers.len().saturating_sub(1)
));
}
// 5. Construire un DiscoveredEndpoint minimal pour ce renderer
//
// On reconstruit un endpoint à partir des infos du registry.
// Ça permet de réutiliser HttpXmlDescriptionProvider::build_avtransport_client
// qui sait parser description.xml et trouver le service AVTransport.
let endpoint = DiscoveredEndpoint::new(
renderer.udn.clone(),
renderer.location.clone(),
renderer.server_header.clone(),
renderer.max_age,
);
// 3. Selection by index
let renderer: &RendererInfo = &renderers[renderer_index];
// 6. Construire un client AVTransport à partir de cet endpoint
let provider = HttpXmlDescriptionProvider::new(5);
let client_opt = provider
.build_avtransport_client(&endpoint)
.context("Failed to build AVTransport client from device description")?;
println!("\nSelected renderer (index {}):", renderer_index);
println!(" Name : {}", renderer.friendly_name);
println!(" Model : {}", renderer.model_name);
println!(" Manufacturer: {}", renderer.manufacturer);
println!(" UDN : {}", renderer.udn);
println!(" Location : {}", renderer.location);
let client = match client_opt {
Some(c) => c,
None => {
println!(
"Renderer {} has no AVTransport service (or no controlURL).",
renderer.friendly_name
);
return Ok(());
}
// 4. Get AVTransport client
let avtransport = reg
.avtransport_client_for_renderer(&renderer.id)
.expect("Selected renderer has no AVTransport service");
println!(" AVTransport control URL : {}", avtransport.control_url);
println!(" AVTransport service type: {}", avtransport.service_type);
// We are done with the registry lock
drop(reg);
// 5. Configure the URI
let meta = ""; // or a full DIDL-Lite string
println!("\nCalling SetAVTransportURI...");
avtransport.set_av_transport_uri(&uri, meta)?;
println!(" SetAVTransportURI: OK");
// Helper closure to dump current TransportInfo
let dump_info = |label: &str| -> Result<()> {
let info = avtransport.get_transport_info(0)?;
println!("\n[{}]", label);
println!(" State : {}", info.current_transport_state);
println!(" Status : {}", info.current_transport_status);
println!(" Speed : {}", info.current_speed);
Ok(())
};
println!(
"AVTransport endpoint:\n service_type = {}\n control_url = {}",
client.service_type, client.control_url
);
dump_info("After SetAVTransportURI")?;
// 7. Appeler GetTransportInfo(InstanceID=0)
println!("\nCalling GetTransportInfo (InstanceID = 0)...");
let info = client
.get_transport_info(0)
.context("GetTransportInfo SOAP call failed")?;
// 6. Play
println!("\nCalling Play (Speed=\"1\")...");
avtransport.play(0, "1")?;
println!(" Play: OK");
thread::sleep(Duration::from_secs(20));
dump_info("After Play")?;
println!("GetTransportInfo result:");
println!(" CurrentTransportState = {}", info.current_transport_state);
println!(" CurrentTransportStatus = {}", info.current_transport_status);
println!(" CurrentSpeed = {}", info.current_speed);
// 7. Optional: wait before Pause/Stop
print!("\nPress ENTER to Pause...");
io::stdout().flush().ok();
let _ = io::stdin().read_line(&mut String::new());
// 8. Pause
println!("\nCalling Pause...");
if let Err(e) = avtransport.pause(0) {
println!(" Pause failed: {e}");
} else {
println!(" Pause: OK");
thread::sleep(Duration::from_secs(2));
dump_info("After Pause")?;
}
// 9. Stop
println!("\nCalling Stop...");
if let Err(e) = avtransport.stop(0) {
println!(" Stop failed: {e}");
} else {
println!(" Stop: OK");
dump_info("After Stop")?;
}
println!("\nDone.");
Ok(())
}

View File

@@ -1,5 +1,7 @@
// pmocontrol/src/avtransport_client.rs
use anyhow::{anyhow, Result};
use crate::soap_client::invoke_upnp_action;
use crate::soap_client::{invoke_upnp_action, SoapCallResult};
use pmoupnp::soap::SoapEnvelope;
use xmltree::{Element, XMLNode};
@@ -24,6 +26,7 @@ impl AvTransportClient {
}
}
/// AVTransport:1 — GetTransportInfo
pub fn get_transport_info(&self, instance_id: u32) -> Result<TransportInfo> {
let instance_id_str = instance_id.to_string();
let args = [("InstanceID", instance_id_str.as_str())];
@@ -49,6 +52,131 @@ impl AvTransportClient {
parse_transport_info(envelope)
}
/// AVTransport:1 — SetAVTransportURI
///
/// Pour linstant on force `InstanceID = 0`, ce qui couvre la majorité
/// des MediaRenderers UPnP AV (un seul instance de transport).
///
/// - `uri` : CurrentURI
/// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide)
pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<()> {
let args = [
("InstanceID", "0"),
("CurrentURI", uri),
("CurrentURIMetaData", meta),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"SetAVTransportURI",
&args,
)?;
handle_action_response("SetAVTransportURI", &call_result)
}
/// AVTransport:1 — Play
pub fn play(&self, instance_id: u32, speed: &str) -> Result<()> {
let instance_id_str = instance_id.to_string();
let args = [
("InstanceID", instance_id_str.as_str()),
("Speed", speed),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"Play",
&args,
)?;
handle_action_response("Play", &call_result)
}
/// AVTransport:1 — Pause
pub fn pause(&self, instance_id: u32) -> Result<()> {
let instance_id_str = instance_id.to_string();
let args = [("InstanceID", instance_id_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"Pause",
&args,
)?;
handle_action_response("Pause", &call_result)
}
/// AVTransport:1 — Stop
pub fn stop(&self, instance_id: u32) -> Result<()> {
let instance_id_str = instance_id.to_string();
let args = [("InstanceID", instance_id_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"Stop",
&args,
)?;
handle_action_response("Stop", &call_result)
}
/// AVTransport:1 — Seek
pub fn seek(&self, instance_id: u32, unit: &str, target: &str) -> Result<()> {
let instance_id_str = instance_id.to_string();
let args = [
("InstanceID", instance_id_str.as_str()),
("Unit", unit),
("Target", target),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"Seek",
&args,
)?;
handle_action_response("Seek", &call_result)
}
}
fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> {
if !call_result.status.is_success() {
if let Some(env) = &call_result.envelope {
if let Some(upnp_error) = parse_upnp_error(env) {
return Err(anyhow!(
"{action} failed with UPnP error {}: {} (HTTP status {})",
upnp_error.error_code,
upnp_error.error_description,
call_result.status
));
}
}
return Err(anyhow!(
"{action} failed with HTTP status {} and body: {}",
call_result.status,
call_result.raw_body
));
}
if let Some(env) = &call_result.envelope {
if let Some(upnp_error) = parse_upnp_error(env) {
return Err(anyhow!(
"{action} returned UPnP error {}: {} (HTTP status {})",
upnp_error.error_code,
upnp_error.error_description,
call_result.status
));
}
}
Ok(())
}
fn parse_transport_info(envelope: &SoapEnvelope) -> Result<TransportInfo> {
@@ -68,6 +196,64 @@ fn parse_transport_info(envelope: &SoapEnvelope) -> Result<TransportInfo> {
})
}
/// Représente une erreur UPnP extraite dun SOAP Fault.
#[derive(Debug, Clone)]
struct UpnpError {
pub error_code: u32,
pub error_description: String,
}
/// Parse un éventuel SOAP Fault contenant un UPnPError.
///
/// Schéma typique (SOAP 1.1) :
///
/// ```xml
/// <s:Body>
/// <s:Fault>
/// <faultcode>...</faultcode>
/// <faultstring>...</faultstring>
/// <detail>
/// <UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
/// <errorCode>401</errorCode>
/// <errorDescription>Invalid Action</errorDescription>
/// </UPnPError>
/// </detail>
/// </s:Fault>
/// </s:Body>
/// ```
fn parse_upnp_error(envelope: &SoapEnvelope) -> Option<UpnpError> {
let fault = find_child_with_suffix(&envelope.body.content, "Fault")?;
let detail = find_child_with_suffix(fault, "detail")?;
let upnp_error = find_child_with_suffix(detail, "UPnPError")?;
// errorCode (obligatoire dans la spec)
let error_code_elem = upnp_error.children.iter().find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem),
_ => None,
})?;
let binding = error_code_elem.get_text()?;
let error_code_text = binding.trim();
let error_code = error_code_text.parse::<u32>().ok()?;
// errorDescription (optionnel, mais utile)
let error_description = upnp_error
.children
.iter()
.find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => {
elem.get_text().map(|t| t.trim().to_string())
}
_ => None,
})
.unwrap_or_else(|| String::from(""));
Some(UpnpError {
error_code,
error_description,
})
}
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
parent.children.iter().find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
@@ -110,7 +296,9 @@ mod tests {
"CurrentTransportStatus",
"OK",
)));
response.children.push(XMLNode::Element(text_element("CurrentSpeed", "1")));
response
.children
.push(XMLNode::Element(text_element("CurrentSpeed", "1")));
let mut body = Element::new("s:Body");
body.children.push(XMLNode::Element(response));
@@ -126,3 +314,45 @@ mod tests {
assert_eq!(info.current_speed, "1");
}
}
#[cfg(test)]
mod upnp_error_tests {
use super::*;
use pmoupnp::soap::{SoapBody, SoapEnvelope};
fn text_element(name: &str, text: &str) -> Element {
let mut elem = Element::new(name);
elem.children.push(XMLNode::Text(text.to_string()));
elem
}
#[test]
fn parse_upnp_error_extracts_error_code_and_description() {
let error_code = text_element("errorCode", "401");
let error_description = text_element("errorDescription", "Invalid Action");
let mut upnp_error = Element::new("UPnPError");
upnp_error.children.push(XMLNode::Element(error_code));
upnp_error
.children
.push(XMLNode::Element(error_description));
let mut detail = Element::new("detail");
detail.children.push(XMLNode::Element(upnp_error));
let mut fault = Element::new("s:Fault");
fault.children.push(XMLNode::Element(detail));
let mut body = Element::new("s:Body");
body.children.push(XMLNode::Element(fault));
let envelope = SoapEnvelope {
header: None,
body: SoapBody { content: body },
};
let err = parse_upnp_error(&envelope).expect("Expected UPnPError");
assert_eq!(err.error_code, 401);
assert_eq!(err.error_description, "Invalid Action");
}
}

View File

@@ -6,8 +6,10 @@ use std::time::Duration;
use pmoupnp::ssdp::SsdpClient;
use crate::discovery::DiscoveryManager;
use crate::model::RendererId;
use crate::provider::HttpXmlDescriptionProvider;
use crate::registry::{DeviceRegistry, DeviceUpdate};
use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
use crate::renderer::Renderer;
/// Control point minimal :
/// - lance un SsdpClient dans un thread,
@@ -76,4 +78,29 @@ impl ControlPoint {
pub fn registry(&self) -> Arc<RwLock<DeviceRegistry>> {
Arc::clone(&self.registry)
}
/// Snapshot list of renderers currently known by the registry.
pub fn list_renderer_handles(&self) -> Vec<Renderer> {
let reg = self.registry.read().unwrap();
reg.list_renderers()
.into_iter()
.map(|info| Renderer::from_registry(info, &reg))
.collect()
}
/// Return the first renderer in the registry, if any.
pub fn default_renderer(&self) -> Option<Renderer> {
let reg = self.registry.read().unwrap();
reg.list_renderers()
.into_iter()
.next()
.map(|info| Renderer::from_registry(info, &reg))
}
/// Lookup a renderer by id.
pub fn renderer_by_id(&self, id: &RendererId) -> Option<Renderer> {
let reg = self.registry.read().unwrap();
reg.get_renderer(id)
.map(|info| Renderer::from_registry(info, &reg))
}
}

View File

@@ -3,11 +3,15 @@ pub mod control_point;
pub mod discovery;
pub mod model;
pub mod provider;
pub mod renderer;
pub mod rendering_control_client;
pub mod registry;
pub mod soap_client;
pub use avtransport_client::{AvTransportClient, TransportInfo};
pub use control_point::ControlPoint;
pub use rendering_control_client::RenderingControlClient;
pub use renderer::Renderer;
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
pub use model::{

View File

@@ -43,6 +43,8 @@ pub struct RendererInfo {
pub avtransport_service_type: Option<String>,
pub avtransport_control_url: Option<String>,
pub rendering_control_service_type: Option<String>,
pub rendering_control_control_url: Option<String>,
}
#[derive(Clone, Debug, Default)]

View File

@@ -42,6 +42,10 @@ struct ParsedDeviceDescription {
// New: AVTransport endpoint (if present in serviceList)
avtransport_service_type: Option<String>,
avtransport_control_url: Option<String>,
// RenderingControl endpoint (if present in serviceList)
rendering_control_service_type: Option<String>,
rendering_control_control_url: Option<String>,
}
impl ParsedDeviceDescription {
@@ -161,6 +165,19 @@ impl HttpXmlDescriptionProvider {
);
}
}
if lower
.contains("urn:schemas-upnp-org:service:renderingcontrol:")
{
if parsed.rendering_control_service_type.is_none() {
parsed.rendering_control_service_type = Some(st.clone());
parsed.rendering_control_control_url = Some(ctrl.clone());
debug!(
"Found RenderingControl service for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
);
}
}
}
in_service = false;
@@ -260,6 +277,11 @@ impl HttpXmlDescriptionProvider {
.avtransport_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
rendering_control_service_type: parsed.rendering_control_service_type.clone(),
rendering_control_control_url: parsed
.rendering_control_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
})
}
@@ -463,4 +485,4 @@ impl DeviceDescriptionProvider for HttpXmlDescriptionProvider {
}
}
}
}
}

View File

@@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::time::SystemTime;
use crate::avtransport_client::AvTransportClient;
use crate::rendering_control_client::RenderingControlClient;
use crate::model::{MediaServerId, MediaServerInfo, RendererId, RendererInfo};
#[derive(Clone, Debug)]
@@ -158,4 +159,20 @@ impl DeviceRegistry {
Some(AvTransportClient::new(control_url, service_type))
}
/// Construct a RenderingControlClient for a given renderer id, if possible.
pub fn rendering_control_client_for_renderer(
&self,
id: &RendererId,
) -> Option<RenderingControlClient> {
let info = self.renderers.get(id)?;
let service_type = info.rendering_control_service_type.as_ref()?;
let control_url = info.rendering_control_control_url.as_ref()?;
Some(RenderingControlClient::new(
control_url.clone(),
service_type.clone(),
))
}
}

152
pmocontrol/src/renderer.rs Normal file
View File

@@ -0,0 +1,152 @@
use anyhow::{anyhow, Result};
use crate::rendering_control_client::RenderingControlClient;
use crate::{AvTransportClient, DeviceRegistry, RendererId, RendererInfo};
/// High-level handle representing a renderer and its optional AVTransport client.
pub struct Renderer {
pub info: RendererInfo,
avtransport: Option<AvTransportClient>,
rendering_control: Option<RenderingControlClient>,
}
impl Renderer {
pub fn id(&self) -> &RendererId {
&self.info.id
}
pub fn friendly_name(&self) -> &str {
&self.info.friendly_name
}
pub fn has_avtransport(&self) -> bool {
self.avtransport.is_some()
}
pub fn has_rendering_control(&self) -> bool {
self.rendering_control.is_some()
}
pub fn avtransport(&self) -> Result<&AvTransportClient> {
self.avtransport
.as_ref()
.ok_or_else(|| anyhow!("Renderer has no AVTransport service"))
}
pub fn rendering_control(&self) -> Result<&RenderingControlClient> {
self.rendering_control
.as_ref()
.ok_or_else(|| anyhow!("Renderer has no RenderingControl service"))
}
pub fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
let avt = self.avtransport()?;
avt.set_av_transport_uri(uri, meta)?;
avt.play(0, "1")
}
pub fn pause(&self) -> Result<()> {
let avt = self.avtransport()?;
avt.pause(0)
}
pub fn stop(&self) -> Result<()> {
let avt = self.avtransport()?;
avt.stop(0)
}
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
let avt = self.avtransport()?;
avt.seek(0, "REL_TIME", hhmmss)
}
pub fn get_master_volume(&self) -> Result<u16> {
let rc = self.rendering_control()?;
rc.get_volume(0, "Master")
}
pub fn set_master_volume(&self, volume: u16) -> Result<()> {
let rc = self.rendering_control()?;
rc.set_volume(0, "Master", volume)
}
pub fn get_master_mute(&self) -> Result<bool> {
let rc = self.rendering_control()?;
rc.get_mute(0, "Master")
}
pub fn set_master_mute(&self, mute: bool) -> Result<()> {
let rc = self.rendering_control()?;
rc.set_mute(0, "Master", mute)
}
pub fn from_registry(info: RendererInfo, registry: &DeviceRegistry) -> Self {
let avtransport = registry.avtransport_client_for_renderer(&info.id);
let rendering_control = registry.rendering_control_client_for_renderer(&info.id);
Self {
info,
avtransport,
rendering_control,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{RendererCapabilities, RendererProtocol};
use crate::registry::{DeviceRegistry, DeviceUpdate};
use std::time::SystemTime;
fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo {
RendererInfo {
id: RendererId(format!("renderer-{id_suffix}")),
udn: format!("uuid:renderer-{id_suffix}"),
friendly_name: format!("Renderer {id_suffix}"),
model_name: "Model".into(),
manufacturer: "Manufacturer".into(),
protocol: RendererProtocol::UpnpAvOnly,
capabilities: RendererCapabilities {
has_avtransport: with_avtransport,
..RendererCapabilities::default()
},
location: "http://127.0.0.1/device.xml".into(),
server_header: "TestServer/1.0".into(),
online: true,
last_seen: SystemTime::now(),
max_age: 1800,
avtransport_service_type: with_avtransport
.then(|| "urn:schemas-upnp-org:service:AVTransport:1".into()),
avtransport_control_url: with_avtransport
.then(|| "http://127.0.0.1/avtransport".into()),
rendering_control_service_type: None,
rendering_control_control_url: None,
}
}
fn registry_with_renderer(info: RendererInfo) -> DeviceRegistry {
let mut registry = DeviceRegistry::new();
registry.apply_update(DeviceUpdate::RendererOnline(info));
registry
}
#[test]
fn renderer_without_avtransport() {
let info = renderer_info("no-avt", false);
let registry = registry_with_renderer(info.clone());
let renderer = Renderer::from_registry(info, &registry);
assert_eq!(renderer.has_avtransport(), false);
assert_eq!(renderer.id().0, "renderer-no-avt");
}
#[test]
fn renderer_with_avtransport() {
let info = renderer_info("with-avt", true);
let registry = registry_with_renderer(info.clone());
let renderer = Renderer::from_registry(info, &registry);
assert!(renderer.has_avtransport());
assert_eq!(renderer.friendly_name(), "Renderer with-avt");
}
}

View File

@@ -0,0 +1,248 @@
use anyhow::{anyhow, Result};
use crate::soap_client::{invoke_upnp_action, SoapCallResult};
use pmoupnp::soap::SoapEnvelope;
use xmltree::{Element, XMLNode};
#[derive(Debug, Clone)]
pub struct RenderingControlClient {
pub control_url: String,
pub service_type: String,
}
impl RenderingControlClient {
pub fn new(control_url: String, service_type: String) -> Self {
Self {
control_url,
service_type,
}
}
/// RenderingControl:1 — GetVolume
pub fn get_volume(&self, instance_id: u32, channel: &str) -> Result<u16> {
let instance_id_str = instance_id.to_string();
let args = [
("InstanceID", instance_id_str.as_str()),
("Channel", channel),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"GetVolume",
&args,
)?;
ensure_success("GetVolume", &call_result)?;
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetVolume response"))?;
if let Some(err) = parse_upnp_error(envelope) {
return Err(anyhow!(
"GetVolume returned UPnP error {}: {} (HTTP status {})",
err.error_code,
err.error_description,
call_result.status
));
}
let response =
find_child_with_suffix(&envelope.body.content, "GetVolumeResponse")
.ok_or_else(|| anyhow!("Missing GetVolumeResponse element in SOAP body"))?;
let text = extract_child_text(response, "CurrentVolume")?;
let volume = text
.parse::<u16>()
.map_err(|_| anyhow!("Invalid CurrentVolume value: {}", text))?;
Ok(volume)
}
/// RenderingControl:1 — SetVolume
pub fn set_volume(&self, instance_id: u32, channel: &str, volume: u16) -> Result<()> {
let instance_id_str = instance_id.to_string();
let volume_str = volume.to_string();
let args = [
("InstanceID", instance_id_str.as_str()),
("Channel", channel),
("DesiredVolume", volume_str.as_str()),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"SetVolume",
&args,
)?;
handle_action_response("SetVolume", &call_result)
}
/// RenderingControl:1 — GetMute
pub fn get_mute(&self, instance_id: u32, channel: &str) -> Result<bool> {
let instance_id_str = instance_id.to_string();
let args = [
("InstanceID", instance_id_str.as_str()),
("Channel", channel),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"GetMute",
&args,
)?;
ensure_success("GetMute", &call_result)?;
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetMute response"))?;
if let Some(err) = parse_upnp_error(envelope) {
return Err(anyhow!(
"GetMute returned UPnP error {}: {} (HTTP status {})",
err.error_code,
err.error_description,
call_result.status
));
}
let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse")
.ok_or_else(|| anyhow!("Missing GetMuteResponse element in SOAP body"))?;
let text = extract_child_text(response, "CurrentMute")?;
let mute = match text.as_str() {
"0" => false,
"1" => true,
_ => {
return Err(anyhow!(
"Invalid CurrentMute value: {} (expected 0 or 1)",
text
))
}
};
Ok(mute)
}
/// RenderingControl:1 — SetMute
pub fn set_mute(&self, instance_id: u32, channel: &str, mute: bool) -> Result<()> {
let instance_id_str = instance_id.to_string();
let mute_str = if mute { "1" } else { "0" };
let args = [
("InstanceID", instance_id_str.as_str()),
("Channel", channel),
("DesiredMute", mute_str),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"SetMute",
&args,
)?;
handle_action_response("SetMute", &call_result)
}
}
fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<()> {
if call_result.status.is_success() {
return Ok(());
}
if let Some(env) = &call_result.envelope {
if let Some(err) = parse_upnp_error(env) {
return Err(anyhow!(
"{action} failed with UPnP error {}: {} (HTTP status {})",
err.error_code,
err.error_description,
call_result.status
));
}
}
Err(anyhow!(
"{action} failed with HTTP status {} and body: {}",
call_result.status,
call_result.raw_body
))
}
fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> {
ensure_success(action, call_result)?;
if let Some(env) = &call_result.envelope {
if let Some(err) = parse_upnp_error(env) {
return Err(anyhow!(
"{action} returned UPnP error {}: {} (HTTP status {})",
err.error_code,
err.error_description,
call_result.status
));
}
}
Ok(())
}
#[derive(Debug, Clone)]
struct UpnpError {
pub error_code: u32,
pub error_description: String,
}
fn parse_upnp_error(envelope: &SoapEnvelope) -> Option<UpnpError> {
let fault = find_child_with_suffix(&envelope.body.content, "Fault")?;
let detail = find_child_with_suffix(fault, "detail")?;
let upnp_error = find_child_with_suffix(detail, "UPnPError")?;
let error_code_elem = upnp_error.children.iter().find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem),
_ => None,
})?;
let binding = error_code_elem.get_text()?;
let error_code_text = binding.trim();
let error_code = error_code_text.parse::<u32>().ok()?;
let error_description = upnp_error
.children
.iter()
.find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => {
elem.get_text().map(|t| t.trim().to_string())
}
_ => None,
})
.unwrap_or_else(|| String::from(""));
Some(UpnpError {
error_code,
error_description,
})
}
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
parent.children.iter().find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
_ => None,
})
}
fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
let child = find_child_with_suffix(parent, suffix)
.ok_or_else(|| anyhow!("Missing {suffix} element in response"))?;
let text = child
.get_text()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.ok_or_else(|| anyhow!("{suffix} element missing text in response"))?;
Ok(text)
}