Add a ArylicTCPRenderer
This commit is contained in:
119
pmocontrol/examples/arylic_tcp_demo.rs
Normal file
119
pmocontrol/examples/arylic_tcp_demo.rs
Normal 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(), ®))
|
||||
.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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
644
pmocontrol/src/arylic_tcp.rs
Normal file
644
pmocontrol/src/arylic_tcp.rs
Normal 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 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<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(|_| ())
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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('-')) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 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<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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user