Add a ArylicTCPRenderer

This commit is contained in:
2025-11-30 23:28:30 +01:00
parent 0e70a91ff9
commit 14f6b0b379
18 changed files with 942 additions and 182 deletions

View File

@@ -0,0 +1,119 @@
use std::io;
use std::thread;
use std::time::Duration;
use pmocontrol::{
ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo,
PlaybackState, PlaybackStatus, TransportControl, VolumeControl,
};
fn main() -> io::Result<()> {
let _ = tracing_subscriber::fmt::try_init();
println!("Starting PMOMusic Arylic TCP demo...");
let cp = ControlPoint::spawn(5)?;
println!("Waiting 5 seconds for SSDP discovery...");
thread::sleep(Duration::from_secs(5));
let registry = cp.registry();
let renderers = {
let reg = registry.read().unwrap();
reg.list_renderers()
};
if renderers.is_empty() {
println!("No renderers discovered.");
return Ok(());
}
println!("Discovered renderers:");
for (idx, info) in renderers.iter().enumerate() {
println!(
" [{}] {} | model={} | Arylic TCP={} | LinkPlay HTTP={}",
idx,
info.friendly_name,
info.model_name,
info.capabilities.has_arylic_tcp,
info.capabilities.has_linkplay_http
);
}
let arylic_renderers: Vec<MusicRenderer> = {
let reg = registry.read().unwrap();
renderers
.iter()
.filter(|info| info.capabilities.has_arylic_tcp)
.filter_map(|info| MusicRenderer::from_registry_info(info.clone(), &reg))
.collect()
};
if arylic_renderers.is_empty() {
println!("\nNo Arylic TCP-capable renderers detected.");
return Ok(());
}
println!("\nArylic TCP renderer status:");
for renderer in arylic_renderers {
println!("- {} ({})", renderer.friendly_name(), renderer.id().0);
match renderer.playback_state() {
Ok(state) => println!(" state: {}", format_playback_state(&state)),
Err(err) => println!(" state: error: {}", err),
}
match renderer.playback_position() {
Ok(pos) => println!(" position: {}", format_position(&pos)),
Err(err) => println!(" position: error: {}", err),
}
match renderer.volume() {
Ok(vol) => println!(" volume: {}", vol),
Err(err) => println!(" volume: error: {}", err),
}
match renderer.mute() {
Ok(mute) => println!(" mute: {}", mute),
Err(err) => println!(" mute: error: {}", err),
}
println!(" attempting pause/play test...");
if let Err(err) = renderer.pause() {
println!(" pause error: {}", err);
} else {
thread::sleep(Duration::from_millis(500));
println!(" pause OK");
}
if let Err(err) = renderer.play() {
println!(" play error: {}", err);
} else {
println!(" play OK");
}
println!();
}
Ok(())
}
fn format_playback_state(state: &PlaybackState) -> String {
match state {
PlaybackState::Stopped => "Stopped".to_string(),
PlaybackState::Playing => "Playing".to_string(),
PlaybackState::Paused => "Paused".to_string(),
PlaybackState::Transitioning => "Transitioning".to_string(),
PlaybackState::NoMedia => "NoMedia".to_string(),
PlaybackState::Unknown(raw) => format!("Unknown({})", raw),
}
}
fn format_position(pos: &PlaybackPositionInfo) -> String {
let rel = pos.rel_time.as_deref().unwrap_or("-");
let dur = pos.track_duration.as_deref().unwrap_or("-");
let track = pos
.track
.map(|t| t.to_string())
.unwrap_or_else(|| "-".to_string());
format!("track={} rel_time={} duration={}", track, rel, dur)
}

View File

@@ -152,17 +152,12 @@ fn format_playback_state(state: &PlaybackState) -> String {
/// Affichage lisible d'un PlaybackPositionInfo.
fn format_position(pos: &PlaybackPositionInfo) -> String {
let track = pos.track.map(|t| t.to_string()).unwrap_or_else(|| "-".to_string());
let rel = pos
.rel_time
.as_deref()
.unwrap_or("-")
.to_string();
let dur = pos
.track_duration
.as_deref()
.unwrap_or("-")
.to_string();
let track = pos
.track
.map(|t| t.to_string())
.unwrap_or_else(|| "-".to_string());
let rel = pos.rel_time.as_deref().unwrap_or("-").to_string();
let dur = pos.track_duration.as_deref().unwrap_or("-").to_string();
format!("track={} rel_time={} duration={}", track, rel, dur)
}

View File

@@ -23,8 +23,7 @@ use std::thread;
use std::time::Duration;
// Default URI if none is provided on the CLI.
const DEFAULT_TEST_URI: &str =
"https://audio-fb.radioparadise.com/chan/1/x/1117/4/g/1117-3.flac";
const DEFAULT_TEST_URI: &str = "https://audio-fb.radioparadise.com/chan/1/x/1117/4/g/1117-3.flac";
// Extra wait after play_uri() so slow renderers (e.g. Arylic H50) have time
// to prefetch and actually start playback.
@@ -103,48 +102,42 @@ fn main() -> Result<()> {
if let Some(upnp) = renderer.as_upnp() {
println!(
" [UPnP] AVTransport control URL : {}",
upnp
.info
upnp.info
.avtransport_control_url
.as_deref()
.unwrap_or("<none>")
);
println!(
" [UPnP] AVTransport service type: {}",
upnp
.info
upnp.info
.avtransport_service_type
.as_deref()
.unwrap_or("<none>")
);
println!(
" [UPnP] RenderingControl control URL : {}",
upnp
.info
upnp.info
.rendering_control_control_url
.as_deref()
.unwrap_or("<none>")
);
println!(
" [UPnP] RenderingControl service type: {}",
upnp
.info
upnp.info
.rendering_control_service_type
.as_deref()
.unwrap_or("<none>")
);
println!(
" [UPnP] ConnectionManager control URL : {}",
upnp
.info
upnp.info
.connection_manager_control_url
.as_deref()
.unwrap_or("<none>")
);
println!(
" [UPnP] ConnectionManager service type: {}",
upnp
.info
upnp.info
.connection_manager_service_type
.as_deref()
.unwrap_or("<none>")
@@ -223,6 +216,7 @@ fn print_capabilities(prefix: &str, caps: &RendererCapabilities, proto: &Rendere
println!("{prefix} RendControl : {}", caps.has_rendering_control);
println!("{prefix} ConnManager : {}", caps.has_connection_manager);
println!("{prefix} LinkPlay HTTP : {}", caps.has_linkplay_http);
println!("{prefix} Arylic TCP : {}", caps.has_arylic_tcp);
println!("{prefix} OH Playlist : {}", caps.has_oh_playlist);
println!("{prefix} OH Volume : {}", caps.has_oh_volume);
println!("{prefix} OH Info : {}", caps.has_oh_info);
@@ -234,6 +228,8 @@ fn print_backend(prefix: &str, renderer: &MusicRenderer) {
let backend = match renderer {
MusicRenderer::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",
MusicRenderer::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)",
MusicRenderer::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)",
MusicRenderer::HybridUpnpArylic{..} => "Hybrid UpnpArylicRenderer (UPnP AV / DLNA + ARylic TCP Protocol)",
};
println!("{prefix}Backend : {backend}");
}
@@ -293,12 +289,7 @@ fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> {
Ok(())
}
fn progress_monitor(
renderer: &MusicRenderer,
label: &str,
iterations: usize,
interval_secs: u64,
) {
fn progress_monitor(renderer: &MusicRenderer, label: &str, iterations: usize, interval_secs: u64) {
println!(
"\n[{label}] polling playback state/position {} times (every {} s)...",
iterations, interval_secs
@@ -343,14 +334,16 @@ fn volume_demo(renderer: &MusicRenderer) -> Result<()> {
thread::sleep(Duration::from_millis(500));
// Small volume bump if possible
let new_volume = original.saturating_add(5).min(u16::MAX);
let new_volume = original.saturating_add(10).min(u16::MAX);
println!(" Bumping volume to : {}", new_volume);
renderer.set_volume(new_volume)?;
thread::sleep(Duration::from_secs(1));
println!(" Volume after bump : {}", renderer.volume()?);
thread::sleep(Duration::from_secs(5));
println!(" Restoring original volume: {}", original);
println!(" Volume after reset : {}", renderer.volume()?);
renderer.set_volume(original)?;
thread::sleep(Duration::from_secs(5));
Ok(())
}

View File

@@ -1,4 +1,4 @@
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use pmocontrol::{ControlPoint, DeviceRegistryRead, RendererInfo};
use std::env;
use std::io::{self, Write};
@@ -66,11 +66,7 @@ fn main() -> Result<()> {
for (idx, r) in renderers.iter().enumerate() {
println!(
" [{}] {} | model={} | udn={} | location={}",
idx,
r.friendly_name,
r.model_name,
r.udn,
r.location,
idx, r.friendly_name, r.model_name, r.udn, r.location,
);
}

View File

@@ -0,0 +1,644 @@
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Shutdown, TcpStream, ToSocketAddrs};
use std::sync::{Mutex, OnceLock};
use std::thread;
use std::time::Duration;
use anyhow::anyhow;
use anyhow::{Context, Result};
use tracing::{debug, warn};
use crate::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
VolumeControl,
};
use crate::linkplay::{extract_linkplay_host, parse_flat_json};
use crate::model::{RendererId, RendererInfo};
use std::time::Instant;
// Garde global pour respecter le délai de 200ms entre commandes
static LAST_COMMAND_TIME: OnceLock<Mutex<Instant>> = OnceLock::new();
fn last_command_time() -> &'static Mutex<Instant> {
LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now()))
}
const ARYLIC_TCP_PORT: u16 = 8899;
const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20];
const RESERVED_BYTES: [u8; 8] = [0; 8];
const MAX_RESPONSE_ATTEMPTS: usize = 8;
const DEFAULT_TIMEOUT_SECS: u64 = 3;
static DETECTION_CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
/// Mode dattente de réponse pour une commande TCP Arylic.
enum ResponseMode<'a> {
/// On nattend aucune réponse (fire-and-forget).
None,
/// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès.
Optional(&'a [&'a str]),
/// On attend une réponse, et labsence de réponse est une erreur.
Required(&'a [&'a str]),
}
fn detection_cache() -> &'static Mutex<HashMap<String, bool>> {
DETECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Probe whether the renderer at the given location exposes the Arylic TCP API.
pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool {
let Some(host) = extract_linkplay_host(location) else {
return false;
};
if let Ok(cache) = detection_cache().lock() {
if let Some(result) = cache.get(&host) {
return *result;
}
}
let detected = match try_detect_tcp(&host, timeout) {
Ok(_) => true,
Err(err) => {
debug!(
"Arylic TCP detection failed for {} (host={}): {}",
location, host, err
);
false
}
};
if let Ok(mut cache) = detection_cache().lock() {
cache.insert(host, detected);
}
detected
}
fn try_detect_tcp(host: &str, timeout: Duration) -> Result<()> {
let payload = send_command_required(
host,
ARYLIC_TCP_PORT,
timeout,
"MCU+INF+GET",
&["AXX+INF+", "AXX+DEV+"],
)?;
if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") {
Ok(())
} else {
Err(anyhow!(
"Unexpected INF response from {}: {}",
host,
payload
))
}
}
/// Backend speaking the Arylic TCP control protocol (port 8899).
#[derive(Clone, Debug)]
pub struct ArylicTcpRenderer {
pub info: RendererInfo,
host: String,
port: u16,
timeout: Duration,
}
impl ArylicTcpRenderer {
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
let host = extract_linkplay_host(&info.location)
.ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?;
Ok(Self {
info,
host,
port: ARYLIC_TCP_PORT,
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
})
}
pub fn id(&self) -> &RendererId {
&self.info.id
}
pub fn friendly_name(&self) -> &str {
&self.info.friendly_name
}
fn send_required(&self, cmd: &str, expected: &[&str]) -> Result<String> {
send_command_required(&self.host, self.port, self.timeout, cmd, expected)
}
fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result<Option<String>> {
send_command_optional(&self.host, self.port, self.timeout, cmd, expected)
}
fn send_no_response(&self, cmd: &str) -> Result<()> {
send_command_no_response(&self.host, self.port, self.timeout, cmd)
}
fn fetch_playback_info(&self) -> Result<ArylicPlaybackInfo> {
let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?;
match parse_playback_info(&payload) {
Ok(info) => Ok(info),
Err(err) => {
debug!(
"Failed to parse Arylic playback info for {}: {}",
self.host, err
);
Err(err)
}
}
}
fn format_volume_command(value: u16) -> String {
format!("MCU+VOL+{:03}", value.min(100))
}
fn parse_volume_payload(payload: &str) -> Result<u16> {
let data = payload
.strip_prefix("AXX+VOL+")
.ok_or_else(|| anyhow!("Unexpected volume response: {}", payload))?;
let value: u16 = data
.trim()
.parse()
.with_context(|| format!("Invalid volume value: {}", data))?;
Ok(value.min(100))
}
fn parse_mute_payload(payload: &str) -> Result<bool> {
let data = payload
.strip_prefix("AXX+MUT+")
.ok_or_else(|| anyhow!("Unexpected mute response: {}", payload))?;
match data.trim() {
"000" | "0" => Ok(false),
"001" | "1" => Ok(true),
other => Err(anyhow!("Invalid mute value: {}", other)),
}
}
}
impl TransportControl for ArylicTcpRenderer {
fn play_uri(&self, _uri: &str, _meta: &str) -> Result<()> {
Err(anyhow!(
"Arylic TCP backend does not support direct URL loading. Use UPnP AVTransport SetAVTransportURI instead."
))
}
fn play(&self) -> Result<()> {
self.send_no_response("MCU+PLY-PLA")
}
fn pause(&self) -> Result<()> {
let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?;
Ok(())
}
fn stop(&self) -> Result<()> {
self.send_no_response("MCU+PLY-STP")
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
let _ = parse_hhmmss(hhmmss)?;
Err(anyhow!(
"Arylic TCP seek_rel_time is not implemented yet for this device."
))
}
}
impl VolumeControl for ArylicTcpRenderer {
fn volume(&self) -> Result<u16> {
if let Ok(info) = self.fetch_playback_info() {
if let Some(vol) = info.volume {
return Ok(vol);
}
debug!(
"Arylic playback info for {} missing volume, falling back to VOL GET",
self.host
);
}
let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?;
Self::parse_volume_payload(&payload)
}
fn set_volume(&self, v: u16) -> Result<()> {
let command = Self::format_volume_command(v);
let _ = self.send_optional(&command, &["AXX+VOL+"])?;
Ok(())
}
fn mute(&self) -> Result<bool> {
if let Ok(info) = self.fetch_playback_info() {
if let Some(mute) = info.mute {
return Ok(mute);
}
debug!(
"Arylic playback info for {} missing mute, falling back to MUT GET",
self.host
);
}
let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?;
Self::parse_mute_payload(&payload)
}
fn set_mute(&self, m: bool) -> Result<()> {
let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" };
let payload = self.send_required(command, &["AXX+MUT+"])?;
let _ = Self::parse_mute_payload(&payload)?;
Ok(())
}
}
impl PlaybackStatus for ArylicTcpRenderer {
fn playback_state(&self) -> Result<PlaybackState> {
let info = self.fetch_playback_info()?;
Ok(info.playback_state())
}
}
impl PlaybackPosition for ArylicTcpRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
let info = self.fetch_playback_info()?;
Ok(info.position_info())
}
}
#[derive(Debug)]
struct ArylicPlaybackInfo {
status_raw: String,
curpos_ms: u64,
totlen_ms: u64,
volume: Option<u16>,
mute: Option<bool>,
playlist_size: Option<u32>,
track_index: Option<u32>,
}
impl ArylicPlaybackInfo {
fn playback_state(&self) -> PlaybackState {
match self.status_raw.as_str() {
"play" => PlaybackState::Playing,
"pause" => PlaybackState::Paused,
"stop" => PlaybackState::Stopped,
other => PlaybackState::Unknown(other.to_string()),
}
}
fn position_info(&self) -> PlaybackPositionInfo {
let track = match (self.track_index, self.playlist_size) {
(Some(idx), Some(count)) if count > 0 => Some(idx.min(count)),
(Some(idx), _) => Some(idx),
_ => None,
};
PlaybackPositionInfo {
track,
rel_time: Some(format_hms(self.curpos_ms / 1000)),
abs_time: None,
track_duration: if self.totlen_ms > 0 {
Some(format_hms(self.totlen_ms / 1000))
} else {
None
},
}
}
}
fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo> {
let json_blob = payload
.strip_prefix("AXX+PLY+INF")
.ok_or_else(|| anyhow!("Unexpected playback info prefix: {}", payload))?;
let json_blob = json_blob.trim_end_matches('&').trim();
let map = parse_flat_json(json_blob)?;
let status_raw = map
.get("status")
.cloned()
.ok_or_else(|| anyhow!("Playback info missing `status` field"))?;
let curpos_ms = parse_u64_field(&map, "curpos")?;
let totlen_ms = parse_u64_field(&map, "totlen")?;
let volume = match map.get("vol") {
Some(raw) => match raw.parse::<u16>() {
Ok(value) => Some(value.min(100)),
Err(err) => {
debug!("Invalid Arylic `vol` value {}: {}", raw, err);
None
}
},
None => None,
};
let mute = match map.get("mute") {
Some(value) if value == "1" => Some(true),
Some(value) if value == "0" => Some(false),
Some(other) => {
debug!("Invalid Arylic `mute` value {}", other);
None
}
None => None,
};
let playlist_size = map
.get("plicount")
.and_then(|raw| match raw.parse::<u32>() {
Ok(count) if count > 0 => Some(count),
Ok(_) => None,
Err(err) => {
debug!("Invalid Arylic `plicount` value {}: {}", raw, err);
None
}
});
let track_index = map.get("plicurr").and_then(|raw| match raw.parse::<u32>() {
Ok(idx) if idx > 0 => Some(idx),
Ok(_) => None,
Err(err) => {
debug!("Invalid Arylic `plicurr` value {}: {}", raw, err);
None
}
});
Ok(ArylicPlaybackInfo {
status_raw,
curpos_ms,
totlen_ms,
volume,
mute,
playlist_size,
track_index,
})
}
fn parse_u64_field(map: &HashMap<String, String>, key: &str) -> Result<u64> {
let raw = map
.get(key)
.ok_or_else(|| anyhow!("Playback info missing `{}` field", key))?;
raw.parse::<u64>()
.with_context(|| format!("Invalid `{}` value: {}", key, raw))
}
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream> {
if let Ok(mut last_time) = last_command_time().lock() {
let elapsed = last_time.elapsed();
if elapsed < Duration::from_millis(200) {
let wait = Duration::from_millis(200) - elapsed;
debug!("Waiting {:?} before sending command to respect 200ms interval", wait);
thread::sleep(wait);
}
*last_time = Instant::now();
}
let address = if host.contains(':') {
format!("[{}]:{}", host, port)
} else {
format!("{host}:{port}")
};
let mut last_err = None;
for addr in address
.to_socket_addrs()
.with_context(|| format!("Failed to resolve {}:{}", host, port))?
{
match TcpStream::connect_timeout(&addr, timeout) {
Ok(stream) => {
stream
.set_read_timeout(Some(timeout))
.and_then(|_| stream.set_write_timeout(Some(timeout)))
.with_context(|| format!("Failed to set socket timeouts for {}", address))?;
return Ok(stream);
}
Err(err) => {
last_err = Some((addr, err));
}
}
}
match last_err {
Some((addr, err)) => Err(anyhow!(
"Failed to connect to {} via {}: {}",
host,
addr,
err
)),
None => Err(anyhow!("No socket addresses resolved for {}", address)),
}
}
fn encode_packet(payload: &str) -> Vec<u8> {
let bytes = payload.as_bytes();
let len = bytes.len() as u32;
let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32));
let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len());
out.extend_from_slice(&PACKET_HEADER);
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&checksum.to_le_bytes());
out.extend_from_slice(&RESERVED_BYTES);
out.extend_from_slice(bytes);
out
}
fn read_packet(stream: &mut TcpStream) -> Result<String> {
let mut header = [0u8; 4];
stream.read_exact(&mut header)?;
if header != PACKET_HEADER {
return Err(anyhow!("Invalid Arylic packet header: {:x?}", header));
}
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf)?;
let len = u32::from_le_bytes(len_buf) as usize;
let mut checksum_buf = [0u8; 4];
stream.read_exact(&mut checksum_buf)?;
let expected_checksum = u32::from_le_bytes(checksum_buf);
let mut reserved = [0u8; 8];
stream.read_exact(&mut reserved)?;
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload)?;
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
if actual_checksum != expected_checksum {
warn!(
"Arylic payload checksum mismatch: expected={} actual={}",
expected_checksum, actual_checksum
);
}
Ok(String::from_utf8(payload)?)
}
fn format_hms(secs: u64) -> String {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
format!("{:02}:{:02}:{:02}", h, m, s)
}
fn parse_hhmmss(value: &str) -> Result<u64> {
let parts: Vec<_> = value.split(':').collect();
if parts.len() != 3 {
return Err(anyhow!(
"Invalid time format `{}`. Expected HH:MM:SS.",
value
));
}
let hours: u64 = parts[0]
.parse()
.with_context(|| format!("Invalid hour component in {}", value))?;
let minutes: u64 = parts[1]
.parse()
.with_context(|| format!("Invalid minute component in {}", value))?;
let seconds: u64 = parts[2]
.parse()
.with_context(|| format!("Invalid second component in {}", value))?;
if minutes > 59 || seconds > 59 {
return Err(anyhow!(
"Invalid HH:MM:SS value `{}`. Minutes and seconds must be < 60.",
value
));
}
Ok(hours * 3600 + minutes * 60 + seconds)
}
fn send_command_with_mode(
host: &str,
port: u16,
timeout: Duration,
payload: &str,
mode: ResponseMode<'_>,
) -> Result<Option<String>> {
let mut stream = connect(host, port, timeout)?;
let packet = encode_packet(payload);
stream
.write_all(&packet)
.with_context(|| format!("Failed to write Arylic TCP packet for {}: {}", host, payload))?;
stream.flush().with_context(|| {
format!(
"Failed to flush Arylic TCP stream for {} (command {})",
host, payload
)
})?;
match mode {
ResponseMode::None => {
debug!(
"Arylic TCP fire-and-forget command sent to {}: {}",
host, payload
);
let _ = stream.shutdown(Shutdown::Write);
Ok(None)
}
ResponseMode::Required(expected) => read_expected_response(&mut stream, host, payload, expected)
.map(Some),
ResponseMode::Optional(expected) => {
for _ in 0..MAX_RESPONSE_ATTEMPTS {
match read_packet(&mut stream) {
Ok(response) => {
if expected.iter().any(|p| response.starts_with(p)) {
return Ok(Some(response));
}
debug!(
"Ignoring unsolicited Arylic payload from {}: {}",
host, response
);
}
Err(err) => {
debug!(
"No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.",
payload, host, err
);
return Ok(None);
}
}
}
Err(anyhow!(
"No expected response for optional command {} on {}",
payload,
host
))
}
}
}
fn read_expected_response(
stream: &mut TcpStream,
host: &str,
payload: &str,
expected: &[&str],
) -> Result<String> {
for _ in 0..MAX_RESPONSE_ATTEMPTS {
let response = match read_packet(stream) {
Ok(resp) => resp,
Err(err) => {
return Err(anyhow!(
"Failed to read Arylic TCP response for {} (command {}): {}",
host,
payload,
err
));
}
};
if expected.iter().any(|prefix| response.starts_with(prefix)) {
return Ok(response);
}
debug!(
"Ignoring unsolicited Arylic payload from {}: {}",
host, response
);
}
Err(anyhow!(
"No expected response for command {} on {}",
payload,
host
))
}
fn send_command_required(
host: &str,
port: u16,
timeout: Duration,
payload: &str,
expected: &[&str],
) -> Result<String> {
match send_command_with_mode(host, port, timeout, payload, ResponseMode::Required(expected))? {
Some(s) => Ok(s),
None => Err(anyhow!(
"Arylic TCP: no response payload for required command {}",
payload
)),
}
}
fn send_command_optional(
host: &str,
port: u16,
timeout: Duration,
payload: &str,
expected: &[&str],
) -> Result<Option<String>> {
send_command_with_mode(host, port, timeout, payload, ResponseMode::Optional(expected))
}
fn send_command_no_response(
host: &str,
port: u16,
timeout: Duration,
payload: &str,
) -> Result<()> {
send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ())
}

View File

@@ -1,7 +1,7 @@
// pmocontrol/src/avtransport_client.rs
use anyhow::{anyhow, Result};
use crate::soap_client::{invoke_upnp_action, SoapCallResult};
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
use anyhow::{Result, anyhow};
use pmoupnp::soap::SoapEnvelope;
use xmltree::{Element, XMLNode};
@@ -80,17 +80,9 @@ impl AvTransportClient {
/// 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 args = [("InstanceID", instance_id_str.as_str()), ("Speed", speed)];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"Play",
&args,
)?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Play", &args)?;
handle_action_response("Play", &call_result)
}
@@ -100,12 +92,8 @@ impl AvTransportClient {
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,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &args)?;
handle_action_response("Pause", &call_result)
}
@@ -115,12 +103,7 @@ impl AvTransportClient {
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,
)?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &args)?;
handle_action_response("Stop", &call_result)
}
@@ -134,12 +117,7 @@ impl AvTransportClient {
("Target", target),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"Seek",
&args,
)?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Seek", &args)?;
handle_action_response("Seek", &call_result)
}
@@ -183,10 +161,8 @@ 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_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 {
@@ -395,9 +371,8 @@ impl AvTransportClient {
}
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"))?;
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> {

View File

@@ -29,7 +29,6 @@ pub enum PlaybackState {
Unknown(String),
}
impl PlaybackState {
/// Map a raw UPnP AVTransport CurrentTransportState string
/// to a logical PlaybackState.
@@ -51,7 +50,6 @@ impl PlaybackState {
}
}
/// Generic abstraction for playback status (transport state).
///
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.

View File

@@ -1,6 +1,6 @@
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use crate::soap_client::{invoke_upnp_action, SoapCallResult};
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
use pmoupnp::soap::SoapEnvelope;
use xmltree::{Element, XMLNode};

View File

@@ -1,6 +1,6 @@
use std::sync::{Arc, Mutex};
use crossbeam_channel::{unbounded, Receiver, Sender};
use crossbeam_channel::{Receiver, Sender, unbounded};
use crate::model::RendererEvent;

View File

@@ -1,5 +1,6 @@
mod events;
pub mod arylic_tcp;
pub mod avtransport_client;
pub mod capabilities;
pub mod connection_manager_client;
@@ -7,26 +8,30 @@ pub mod control_point;
pub mod discovery;
pub mod linkplay;
pub mod model;
pub mod provider;
pub mod upnp_renderer;
pub mod rendering_control_client;
pub mod registry;
pub mod soap_client;
pub mod music_renderer;
pub mod provider;
pub mod registry;
pub mod rendering_control_client;
pub mod soap_client;
pub mod upnp_renderer;
pub use avtransport_client::{AvTransportClient, TransportInfo, PositionInfo};
pub use capabilities::{TransportControl, VolumeControl, PlaybackState, PlaybackStatus, PlaybackPosition, PlaybackPositionInfo};
pub use arylic_tcp::ArylicTcpRenderer;
pub use avtransport_client::{AvTransportClient, PositionInfo, TransportInfo};
pub use capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
VolumeControl,
};
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
pub use control_point::ControlPoint;
pub use rendering_control_client::RenderingControlClient;
pub use linkplay::LinkPlayRenderer;
pub use upnp_renderer::UpnpRenderer;
pub use music_renderer::MusicRenderer;
pub use rendering_control_client::RenderingControlClient;
pub use upnp_renderer::UpnpRenderer;
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
pub use model::{
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
RendererEvent, RendererInfo, RendererProtocol,
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererEvent,
RendererId, RendererInfo, RendererProtocol,
};
pub use provider::HttpXmlDescriptionProvider;
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};

View File

@@ -3,7 +3,7 @@ use std::collections::HashMap;
use std::fmt;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use anyhow::{Context, Result, anyhow};
use tracing::debug;
use ureq::Agent;
@@ -283,7 +283,7 @@ fn parse_u16_field(map: &HashMap<String, String>, key: &str) -> Result<u16> {
Ok(value.min(100))
}
fn parse_flat_json(input: &str) -> Result<HashMap<String, String>> {
pub(crate) fn parse_flat_json(input: &str) -> Result<HashMap<String, String>> {
let mut chars = input.chars().peekable();
skip_ws(&mut chars);
if chars.next() != Some('{') {
@@ -348,9 +348,7 @@ fn parse_json_value(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Res
}
}
fn parse_json_string(
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
) -> Result<String> {
fn parse_json_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String> {
if chars.next() != Some('"') {
return Err(anyhow!("Expected string"));
}
@@ -394,9 +392,7 @@ fn parse_json_string(
Err(anyhow!("Unterminated JSON string"))
}
fn parse_json_number(
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
) -> Result<String> {
fn parse_json_number(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String> {
let mut out = String::new();
if matches!(chars.peek(), Some('-')) {

View File

@@ -19,6 +19,7 @@ pub struct RendererCapabilities {
pub has_rendering_control: bool,
pub has_connection_manager: bool,
pub has_linkplay_http: bool,
pub has_arylic_tcp: bool,
pub has_oh_playlist: bool,
pub has_oh_volume: bool,

View File

@@ -3,40 +3,48 @@
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
use crate::model::{RendererId, RendererInfo, RendererProtocol};
use crate::{
DeviceRegistry, LinkPlayRenderer, PlaybackPosition, PlaybackState, TransportControl,
UpnpRenderer, VolumeControl,
ArylicTcpRenderer, DeviceRegistry, LinkPlayRenderer, PlaybackPosition, PlaybackState,
TransportControl, UpnpRenderer, VolumeControl,
};
use anyhow::Result;
use tracing::warn;
use tracing::{debug, warn};
/// Music view of a renderer, independent of the underlying protocol/backend.
///
/// Currently supported backends:
/// - [`UpnpRenderer`] (AVTransport + RenderingControl)
/// - [`LinkPlayRenderer`] (LinkPlay HTTP API)
/// - [`ArylicTcpRenderer`] (Arylic TCP control port 8899)
/// Additional backends (e.g. OpenHome) can be integrated later.
#[derive(Clone, Debug)]
pub enum MusicRenderer {
/// UPnP AV / DLNA backend.
Upnp(UpnpRenderer),
/// LinkPlay HTTP backend (Arylic and similar devices).
LinkPlay(LinkPlayRenderer),
ArylicTcp(ArylicTcpRenderer),
HybridUpnpArylic {
upnp: UpnpRenderer,
arylic: ArylicTcpRenderer,
},
}
impl MusicRenderer {
/// Renderer identifier (stable within the registry).
pub fn id(&self) -> &RendererId {
match self {
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.id(),
MusicRenderer::Upnp(r) => r.id(),
MusicRenderer::LinkPlay(r) => r.id(),
MusicRenderer::ArylicTcp(r) => r.id(),
}
}
/// Human-friendly name reported by the device.
pub fn friendly_name(&self) -> &str {
match self {
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.friendly_name(),
MusicRenderer::Upnp(r) => r.friendly_name(),
MusicRenderer::LinkPlay(r) => r.friendly_name(),
MusicRenderer::ArylicTcp(r) => r.friendly_name(),
}
}
@@ -48,8 +56,10 @@ impl MusicRenderer {
/// Full static info as stored in the registry.
pub fn info(&self) -> &RendererInfo {
match self {
MusicRenderer::HybridUpnpArylic { arylic, .. } => &arylic.info,
MusicRenderer::Upnp(r) => &r.info,
MusicRenderer::LinkPlay(r) => &r.info,
MusicRenderer::ArylicTcp(r) => &r.info,
}
}
@@ -66,25 +76,44 @@ impl MusicRenderer {
/// Returns `None` when no supported backend can be built for this renderer.
/// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported)
/// or [`MusicRenderer::Upnp`].
pub fn from_registry_info(
info: RendererInfo,
registry: &DeviceRegistry,
) -> Option<MusicRenderer> {
pub fn from_registry_info(info: RendererInfo, registry: &DeviceRegistry) -> Option<Self> {
match info.protocol {
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => {
if info.capabilities.has_linkplay_http {
match LinkPlayRenderer::from_renderer_info(info.clone()) {
Ok(renderer) => return Some(MusicRenderer::LinkPlay(renderer)),
Err(err) => warn!(
"Failed to build LinkPlay backend for {}: {}. Falling back to UPnP.",
info.friendly_name, err
),
let has_arylic = info.capabilities.has_arylic_tcp;
let has_avtransport = info.capabilities.has_avtransport;
if has_arylic && has_avtransport {
// Construire UpnpRenderer
let upnp = UpnpRenderer::from_registry(info.clone(), registry);
// Construire ArylicTcpRenderer
match ArylicTcpRenderer::from_renderer_info(info.clone()) {
Ok(arylic) => {
return Some(MusicRenderer::HybridUpnpArylic { upnp, arylic });
}
Err(err) => {
warn!(
"Failed to build Arylic TCP backend for {}: {}. Falling back to UPnP only.",
info.friendly_name, err
);
return Some(MusicRenderer::Upnp(upnp));
}
}
}
Some(MusicRenderer::Upnp(UpnpRenderer::from_registry(info, registry)))
// Pas dArylic : logique existante
if info.capabilities.has_linkplay_http {
if let Ok(lp) = LinkPlayRenderer::from_renderer_info(info.clone()) {
return Some(MusicRenderer::LinkPlay(lp));
}
}
Some(MusicRenderer::Upnp(UpnpRenderer::from_registry(
info, registry,
)))
}
RendererProtocol::OpenHomeOnly => {
// OpenHome-only backend not implemented yet.
// TODO: OH plus tard
None
}
}
@@ -97,36 +126,46 @@ impl MusicRenderer {
impl TransportControl for MusicRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.play_uri(uri, meta),
MusicRenderer::LinkPlay(r) => r.play_uri(uri, meta),
MusicRenderer::Upnp(upnp) => upnp.play_uri(uri, meta),
MusicRenderer::LinkPlay(lp) => lp.play_uri(uri, meta),
MusicRenderer::ArylicTcp(ary) => ary.play_uri(uri, meta),
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
}
}
fn play(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => TransportControl::play(r),
MusicRenderer::LinkPlay(r) => r.play(),
MusicRenderer::Upnp(upnp) => upnp.play(),
MusicRenderer::LinkPlay(lp) => lp.play(),
MusicRenderer::ArylicTcp(ary) => ary.play(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.play(),
}
}
fn pause(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.pause(),
MusicRenderer::LinkPlay(r) => r.pause(),
MusicRenderer::Upnp(upnp) => upnp.pause(),
MusicRenderer::LinkPlay(lp) => lp.pause(),
MusicRenderer::ArylicTcp(ary) => ary.pause(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.pause(),
}
}
fn stop(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.stop(),
MusicRenderer::LinkPlay(r) => r.stop(),
MusicRenderer::Upnp(upnp) => upnp.stop(),
MusicRenderer::LinkPlay(lp) => lp.stop(),
MusicRenderer::ArylicTcp(ary) => ary.stop(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.stop(),
}
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.seek_rel_time(hhmmss),
MusicRenderer::LinkPlay(r) => r.seek_rel_time(hhmmss),
MusicRenderer::Upnp(upnp) => upnp.seek_rel_time(hhmmss),
MusicRenderer::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
MusicRenderer::ArylicTcp(ary) => ary.seek_rel_time(hhmmss),
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss),
}
}
}
@@ -137,29 +176,37 @@ impl TransportControl for MusicRenderer {
impl VolumeControl for MusicRenderer {
fn volume(&self) -> Result<u16> {
match self {
MusicRenderer::Upnp(r) => r.get_master_volume(),
MusicRenderer::LinkPlay(r) => r.volume(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.volume(),
MusicRenderer::ArylicTcp(ary) => ary.volume(),
MusicRenderer::Upnp(upnp) => upnp.volume(),
MusicRenderer::LinkPlay(lp) => lp.volume(),
}
}
fn set_volume(&self, v: u16) -> Result<()> {
fn set_volume(&self, vol: u16) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.set_master_volume(v),
MusicRenderer::LinkPlay(r) => r.set_volume(v),
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
MusicRenderer::ArylicTcp(ary) => ary.set_volume(vol),
MusicRenderer::Upnp(upnp) => upnp.set_volume(vol),
MusicRenderer::LinkPlay(lp) => lp.set_volume(vol),
}
}
fn mute(&self) -> Result<bool> {
match self {
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.mute(),
MusicRenderer::Upnp(r) => r.get_master_mute(),
MusicRenderer::LinkPlay(r) => r.mute(),
MusicRenderer::ArylicTcp(r) => r.mute(),
}
}
fn set_mute(&self, m: bool) -> Result<()> {
match self {
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
MusicRenderer::Upnp(r) => r.set_master_mute(m),
MusicRenderer::LinkPlay(r) => r.set_mute(m),
MusicRenderer::ArylicTcp(r) => r.set_mute(m),
}
}
}
@@ -172,6 +219,8 @@ impl PlaybackStatus for MusicRenderer {
match self {
MusicRenderer::Upnp(r) => PlaybackStatus::playback_state(r),
MusicRenderer::LinkPlay(r) => r.playback_state(),
MusicRenderer::ArylicTcp(r) => r.playback_state(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_state(),
}
}
}
@@ -181,6 +230,8 @@ impl PlaybackPosition for MusicRenderer {
match self {
MusicRenderer::Upnp(r) => r.playback_position(),
MusicRenderer::LinkPlay(r) => r.playback_position(),
MusicRenderer::ArylicTcp(r) => r.playback_position(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
}
}
}

View File

@@ -5,6 +5,7 @@ use quick_xml::{Error as XmlError, Reader, events::Event};
use thiserror::Error;
use tracing::{debug, warn};
use crate::arylic_tcp::detect_arylic_tcp;
use crate::avtransport_client::AvTransportClient;
use crate::discovery::{DeviceDescriptionProvider, DiscoveredEndpoint};
use crate::linkplay::detect_linkplay_http;
@@ -175,8 +176,10 @@ impl HttpXmlDescriptionProvider {
.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());
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
@@ -188,7 +191,8 @@ impl HttpXmlDescriptionProvider {
.contains("urn:schemas-upnp-org:service:connectionmanager:")
{
if parsed.connection_manager_service_type.is_none() {
parsed.connection_manager_service_type = Some(st.clone());
parsed.connection_manager_service_type =
Some(st.clone());
parsed.connection_manager_control_url =
Some(ctrl.clone());
debug!(
@@ -281,6 +285,12 @@ impl HttpXmlDescriptionProvider {
) {
caps.has_linkplay_http = true;
}
if detect_arylic_tcp(
&endpoint.location,
Duration::from_secs(self.timeout_secs.max(1)),
) {
caps.has_arylic_tcp = true;
}
let protocol = detect_renderer_protocol(&caps);
let now = SystemTime::now();

View File

@@ -3,8 +3,8 @@ use std::time::SystemTime;
use crate::avtransport_client::AvTransportClient;
use crate::connection_manager_client::ConnectionManagerClient;
use crate::rendering_control_client::RenderingControlClient;
use crate::model::{MediaServerId, MediaServerInfo, RendererId, RendererInfo};
use crate::rendering_control_client::RenderingControlClient;
#[derive(Clone, Debug)]
enum DeviceKey {
@@ -136,10 +136,7 @@ impl DeviceRegistry {
/// Returns:
/// - Some(client) if the renderer exists AND has avtransport_* fields set
/// - None if renderer not found or no AVTransport service.
pub fn avtransport_client_for_renderer(
&self,
id: &RendererId,
) -> Option<AvTransportClient> {
pub fn avtransport_client_for_renderer(&self, id: &RendererId) -> Option<AvTransportClient> {
let info = self.renderers.get(id)?;
let service_type = info.avtransport_service_type.as_ref()?;

View File

@@ -1,5 +1,5 @@
use anyhow::{anyhow, Result};
use crate::soap_client::{invoke_upnp_action, SoapCallResult};
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
use anyhow::{Result, anyhow};
use pmoupnp::soap::SoapEnvelope;
use xmltree::{Element, XMLNode};
@@ -25,12 +25,8 @@ impl RenderingControlClient {
("Channel", channel),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"GetVolume",
&args,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "GetVolume", &args)?;
ensure_success("GetVolume", &call_result)?;
@@ -48,9 +44,8 @@ impl RenderingControlClient {
));
}
let response =
find_child_with_suffix(&envelope.body.content, "GetVolumeResponse")
.ok_or_else(|| anyhow!("Missing GetVolumeResponse element in SOAP body"))?;
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
@@ -70,12 +65,8 @@ impl RenderingControlClient {
("DesiredVolume", volume_str.as_str()),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"SetVolume",
&args,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "SetVolume", &args)?;
handle_action_response("SetVolume", &call_result)
}
@@ -88,12 +79,8 @@ impl RenderingControlClient {
("Channel", channel),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"GetMute",
&args,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "GetMute", &args)?;
ensure_success("GetMute", &call_result)?;
@@ -122,7 +109,7 @@ impl RenderingControlClient {
return Err(anyhow!(
"Invalid CurrentMute value: {} (expected 0 or 1)",
text
))
));
}
};
@@ -139,12 +126,8 @@ impl RenderingControlClient {
("DesiredMute", mute_str),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"SetMute",
&args,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?;
handle_action_response("SetMute", &call_result)
}

View File

@@ -24,15 +24,12 @@ pub fn invoke_upnp_action(
action: &str,
args: &[(&str, &str)],
) -> Result<SoapCallResult> {
// 1. Build SOAP request XML using pmoupnp
let body_xml = build_soap_request(service_type, action, args)
.context("Failed to build SOAP request body")?;
// 2. Build agent that does NOT treat 4xx/5xx as errors
let config = Agent::config_builder()
.http_status_as_error(false)
.build();
let config = Agent::config_builder().http_status_as_error(false).build();
let agent: Agent = config.into();

View File

@@ -1,11 +1,12 @@
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
use crate::connection_manager_client::{
ConnectionInfo, ConnectionManagerClient, ProtocolInfo,
};
use crate::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
use crate::rendering_control_client::RenderingControlClient;
use crate::{AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererId, RendererInfo, TransportControl, VolumeControl};
use crate::{
AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererId,
RendererInfo, TransportControl, VolumeControl,
};
/// High-level handle representing a renderer and its optional AVTransport client.
#[derive(Clone, Debug)]
@@ -186,7 +187,6 @@ mod tests {
}
}
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
///
/// Cette impl se base sur AVTransport (InstanceID = 0).