diff --git a/pmocontrol/examples/arylic_tcp_demo.rs b/pmocontrol/examples/arylic_tcp_demo.rs new file mode 100644 index 00000000..d6a90afd --- /dev/null +++ b/pmocontrol/examples/arylic_tcp_demo.rs @@ -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 = { + let reg = registry.read().unwrap(); + renderers + .iter() + .filter(|info| info.capabilities.has_arylic_tcp) + .filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®)) + .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) +} diff --git a/pmocontrol/examples/event_demo.rs b/pmocontrol/examples/event_demo.rs index 70600dc0..fe051322 100644 --- a/pmocontrol/examples/event_demo.rs +++ b/pmocontrol/examples/event_demo.rs @@ -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) } diff --git a/pmocontrol/examples/renderer_demo.rs b/pmocontrol/examples/renderer_demo.rs index 52683ffb..046d5894 100644 --- a/pmocontrol/examples/renderer_demo.rs +++ b/pmocontrol/examples/renderer_demo.rs @@ -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("") ); println!( " [UPnP] AVTransport service type: {}", - upnp - .info + upnp.info .avtransport_service_type .as_deref() .unwrap_or("") ); println!( " [UPnP] RenderingControl control URL : {}", - upnp - .info + upnp.info .rendering_control_control_url .as_deref() .unwrap_or("") ); println!( " [UPnP] RenderingControl service type: {}", - upnp - .info + upnp.info .rendering_control_service_type .as_deref() .unwrap_or("") ); println!( " [UPnP] ConnectionManager control URL : {}", - upnp - .info + upnp.info .connection_manager_control_url .as_deref() .unwrap_or("") ); println!( " [UPnP] ConnectionManager service type: {}", - upnp - .info + upnp.info .connection_manager_service_type .as_deref() .unwrap_or("") @@ -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(()) } diff --git a/pmocontrol/examples/test_avtransport.rs b/pmocontrol/examples/test_avtransport.rs index 58302ab1..57dfcdb5 100644 --- a/pmocontrol/examples/test_avtransport.rs +++ b/pmocontrol/examples/test_avtransport.rs @@ -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, ); } diff --git a/pmocontrol/src/arylic_tcp.rs b/pmocontrol/src/arylic_tcp.rs new file mode 100644 index 00000000..73648ed1 --- /dev/null +++ b/pmocontrol/src/arylic_tcp.rs @@ -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> = OnceLock::new(); + +fn last_command_time() -> &'static Mutex { + 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>> = OnceLock::new(); + +/// Mode d’attente de réponse pour une commande TCP Arylic. +enum ResponseMode<'a> { + /// On n’attend 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 l’absence de réponse est une erreur. + Required(&'a [&'a str]), +} + +fn detection_cache() -> &'static Mutex> { + 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 { + 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 { + send_command_required(&self.host, self.port, self.timeout, cmd, expected) + } + + fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let info = self.fetch_playback_info()?; + Ok(info.playback_state()) + } +} + +impl PlaybackPosition for ArylicTcpRenderer { + fn playback_position(&self) -> Result { + 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, + mute: Option, + playlist_size: Option, + track_index: Option, +} + +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 { + 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::() { + 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::() { + 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::() { + 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, key: &str) -> Result { + let raw = map + .get(key) + .ok_or_else(|| anyhow!("Playback info missing `{}` field", key))?; + raw.parse::() + .with_context(|| format!("Invalid `{}` value: {}", key, raw)) +} + +fn connect(host: &str, port: u16, timeout: Duration) -> Result { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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> { + 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(|_| ()) +} diff --git a/pmocontrol/src/avtransport_client.rs b/pmocontrol/src/avtransport_client.rs index 65be8da6..eef3bdb3 100644 --- a/pmocontrol/src/avtransport_client.rs +++ b/pmocontrol/src/avtransport_client.rs @@ -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 { 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 { - 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 { diff --git a/pmocontrol/src/capabilities.rs b/pmocontrol/src/capabilities.rs index 93a003de..29101670 100644 --- a/pmocontrol/src/capabilities.rs +++ b/pmocontrol/src/capabilities.rs @@ -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. diff --git a/pmocontrol/src/connection_manager_client.rs b/pmocontrol/src/connection_manager_client.rs index 47662371..ae468928 100644 --- a/pmocontrol/src/connection_manager_client.rs +++ b/pmocontrol/src/connection_manager_client.rs @@ -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}; diff --git a/pmocontrol/src/events.rs b/pmocontrol/src/events.rs index 900d1cdd..68a00527 100644 --- a/pmocontrol/src/events.rs +++ b/pmocontrol/src/events.rs @@ -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; diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 1dec03ec..129025e1 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -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}; diff --git a/pmocontrol/src/linkplay.rs b/pmocontrol/src/linkplay.rs index 8e19500d..561d30ad 100644 --- a/pmocontrol/src/linkplay.rs +++ b/pmocontrol/src/linkplay.rs @@ -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, key: &str) -> Result { Ok(value.min(100)) } -fn parse_flat_json(input: &str) -> Result> { +pub(crate) fn parse_flat_json(input: &str) -> Result> { 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>) -> Res } } -fn parse_json_string( - chars: &mut std::iter::Peekable>, -) -> Result { +fn parse_json_string(chars: &mut std::iter::Peekable>) -> Result { 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>, -) -> Result { +fn parse_json_number(chars: &mut std::iter::Peekable>) -> Result { let mut out = String::new(); if matches!(chars.peek(), Some('-')) { diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 19a762b6..02ae40b9 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -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, diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs index caaf8eb8..e3306059 100644 --- a/pmocontrol/src/music_renderer.rs +++ b/pmocontrol/src/music_renderer.rs @@ -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 { + pub fn from_registry_info(info: RendererInfo, registry: &DeviceRegistry) -> Option { 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 d’Arylic : 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 { 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 { 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(), } } } diff --git a/pmocontrol/src/provider.rs b/pmocontrol/src/provider.rs index ff8ec1a8..6958e6bb 100644 --- a/pmocontrol/src/provider.rs +++ b/pmocontrol/src/provider.rs @@ -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(); diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs index 3f49d2c9..e9e25f7d 100644 --- a/pmocontrol/src/registry.rs +++ b/pmocontrol/src/registry.rs @@ -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 { + pub fn avtransport_client_for_renderer(&self, id: &RendererId) -> Option { let info = self.renderers.get(id)?; let service_type = info.avtransport_service_type.as_ref()?; diff --git a/pmocontrol/src/rendering_control_client.rs b/pmocontrol/src/rendering_control_client.rs index 0eb3bc46..c9358b73 100644 --- a/pmocontrol/src/rendering_control_client.rs +++ b/pmocontrol/src/rendering_control_client.rs @@ -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) } diff --git a/pmocontrol/src/soap_client.rs b/pmocontrol/src/soap_client.rs index ef5d4df8..8906b33d 100644 --- a/pmocontrol/src/soap_client.rs +++ b/pmocontrol/src/soap_client.rs @@ -24,15 +24,12 @@ pub fn invoke_upnp_action( action: &str, args: &[(&str, &str)], ) -> Result { - // 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(); diff --git a/pmocontrol/src/upnp_renderer.rs b/pmocontrol/src/upnp_renderer.rs index e8f160cd..52d928f1 100644 --- a/pmocontrol/src/upnp_renderer.rs +++ b/pmocontrol/src/upnp_renderer.rs @@ -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). @@ -259,4 +259,4 @@ impl PlaybackPosition for UpnpRenderer { track_duration: raw.track_duration, }) } -} \ No newline at end of file +}