Files
pmomusic/pmocontrol/src/avtransport_client.rs
2025-12-06 12:48:21 +01:00

401 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// pmocontrol/src/avtransport_client.rs
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
use anyhow::{Result, anyhow};
use pmoupnp::soap::SoapEnvelope;
use xmltree::{Element, XMLNode};
#[derive(Debug, Clone)]
pub struct AvTransportClient {
pub control_url: String,
pub service_type: String,
}
#[derive(Debug, Clone)]
pub struct TransportInfo {
pub current_transport_state: String,
pub current_transport_status: String,
pub current_speed: String,
}
impl AvTransportClient {
pub fn new(control_url: String, service_type: String) -> Self {
Self {
control_url,
service_type,
}
}
/// 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())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"GetTransportInfo",
&args,
)?;
if !call_result.status.is_success() {
return Err(anyhow!(
"GetTransportInfo failed with HTTP status {}",
call_result.status
));
}
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetTransportInfo response"))?;
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> {
let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse")
.ok_or_else(|| anyhow!("Missing GetTransportInfoResponse element in SOAP body"))?;
let current_transport_state = extract_child_text(response, "CurrentTransportState")?;
let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?;
let current_speed = extract_child_text(response, "CurrentSpeed")?;
Ok(TransportInfo {
current_transport_state,
current_transport_status,
current_speed,
})
}
/// 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),
_ => 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 GetTransportInfoResponse"))?;
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 GetTransportInfoResponse"))?;
Ok(text)
}
#[cfg(test)]
mod 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_transport_info_extracts_fields() {
let mut response = Element::new("u:GetTransportInfoResponse");
response.children.push(XMLNode::Element(text_element(
"CurrentTransportState",
"STOPPED",
)));
response.children.push(XMLNode::Element(text_element(
"CurrentTransportStatus",
"OK",
)));
response
.children
.push(XMLNode::Element(text_element("CurrentSpeed", "1")));
let mut body = Element::new("s:Body");
body.children.push(XMLNode::Element(response));
let envelope = SoapEnvelope {
header: None,
body: SoapBody { content: body },
};
let info = parse_transport_info(&envelope).unwrap();
assert_eq!(info.current_transport_state, "STOPPED");
assert_eq!(info.current_transport_status, "OK");
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");
}
}
#[derive(Debug, Clone)]
pub struct PositionInfo {
pub track: u32,
pub track_duration: Option<String>, // HH:MM:SS or None
pub rel_time: Option<String>, // HH:MM:SS or None
pub abs_time: Option<String>, // HH:MM:SS or None
}
impl AvTransportClient {
/// AVTransport:1 — GetPositionInfo
pub fn get_position_info(&self, instance_id: u32) -> Result<PositionInfo> {
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,
"GetPositionInfo",
&args,
)?;
if !call_result.status.is_success() {
return Err(anyhow!(
"GetPositionInfo failed with HTTP status {}",
call_result.status
));
}
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetPositionInfo response"))?;
parse_position_info(envelope)
}
}
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo> {
let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse")
.ok_or_else(|| anyhow!("Missing GetPositionInfoResponse element"))?;
// Helpers allow missing text (AVTransport allows empty durations)
fn opt(parent: &Element, name: &str) -> Option<String> {
find_child_with_suffix(parent, name)
.and_then(|e| e.get_text())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
let track = opt(response, "Track")
.unwrap_or_else(|| "0".into())
.parse::<u32>()
.unwrap_or(0);
let track_duration = opt(response, "TrackDuration");
let rel_time = opt(response, "RelTime");
let abs_time = opt(response, "AbsTime");
Ok(PositionInfo {
track,
track_duration,
rel_time,
abs_time,
})
}