feat: implement core Roku ECP adapter
Add SSDP discovery plus the main ECP app, state, and key control flows so Milestone 2 has a working Roku foundation with parser and key-mapping tests.
This commit is contained in:
Generated
+921
-9
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ anyhow = "1.0"
|
|||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "multipart", "rustls-tls"] }
|
||||||
|
roxmltree = "0.20"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
@@ -15,4 +17,5 @@ tokio = { version = "1.0", features = ["full"] }
|
|||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
urlencoding = "2.1"
|
||||||
uuid = { version = "1.0", features = ["serde", "v4"] }
|
uuid = { version = "1.0", features = ["serde", "v4"] }
|
||||||
|
|||||||
+3
-4
@@ -19,7 +19,7 @@ script and control smart TVs through a stable, brand-agnostic API.
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**Phase:** Milestone 1 scaffolded. Foundation compiles; runtime logic not started.
|
**Phase:** Milestone 2 in progress. Core Roku ECP support exists; daemon and CLI wiring are still pending.
|
||||||
**Platform v1:** Roku only (via ECP HTTP API)
|
**Platform v1:** Roku only (via ECP HTTP API)
|
||||||
**Language:** Rust
|
**Language:** Rust
|
||||||
**Crate type:** Binary (single binary distribution target)
|
**Crate type:** Binary (single binary distribution target)
|
||||||
@@ -56,7 +56,7 @@ tvctl/
|
|||||||
│ │ └── mod.rs
|
│ │ └── mod.rs
|
||||||
│ └── adapters/ ← Platform adapters and shared types
|
│ └── adapters/ ← Platform adapters and shared types
|
||||||
│ ├── mod.rs ← Adapter trait definition and core data shapes
|
│ ├── mod.rs ← Adapter trait definition and core data shapes
|
||||||
│ └── roku/ ← Roku ECP adapter scaffold
|
│ └── roku/ ← Roku ECP adapter implementation
|
||||||
│ └── mod.rs
|
│ └── mod.rs
|
||||||
└── cache/ ← Runtime cache (gitignored)
|
└── cache/ ← Runtime cache (gitignored)
|
||||||
```
|
```
|
||||||
@@ -332,11 +332,10 @@ enabled = true
|
|||||||
|
|
||||||
## What Has NOT Been Started
|
## What Has NOT Been Started
|
||||||
|
|
||||||
- Roku ECP transport and device discovery logic
|
|
||||||
- Daemon runtime, socket transport, and persistence logic
|
- Daemon runtime, socket transport, and persistence logic
|
||||||
- HTTP route handlers and request validation
|
- HTTP route handlers and request validation
|
||||||
- Real CLI command handling beyond skeleton parsing
|
- Real CLI command handling beyond skeleton parsing
|
||||||
- Any tests
|
- Integration and hardware validation coverage
|
||||||
- CI/CD configuration
|
- CI/CD configuration
|
||||||
- Release/packaging
|
- Release/packaging
|
||||||
|
|
||||||
|
|||||||
+12
-11
@@ -14,7 +14,7 @@ Foundation scaffold is complete. Begin platform implementation work.
|
|||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
|
||||||
_Nothing in progress yet._
|
- Roku adapter `dev_install()` and `dev_logs()` need developer-web credential handling plus real-device validation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,18 +28,18 @@ _Completed 2026-04-14. See Completed below._
|
|||||||
## Milestone 2 — Roku Adapter
|
## Milestone 2 — Roku Adapter
|
||||||
_Goal: Can communicate with a real Roku TV over ECP._
|
_Goal: Can communicate with a real Roku TV over ECP._
|
||||||
|
|
||||||
- [ ] Implement Roku ECP adapter in `src/adapters/roku/`
|
- [x] 2026-04-14 — Implement core Roku ECP adapter in `src/adapters/roku/`
|
||||||
- [ ] `discover()` — SSDP scan returning Roku devices
|
- [x] 2026-04-14 — `discover()` — SSDP scan returning Roku devices
|
||||||
- [ ] `list_apps()` — fetch installed channel list via ECP
|
- [x] 2026-04-14 — `list_apps()` — fetch installed channel list via ECP
|
||||||
- [ ] `launch()` — launch app by ECP channel ID
|
- [x] 2026-04-14 — `launch()` — launch app by ECP channel ID
|
||||||
- [ ] `stop_app()` — exit current app
|
- [x] 2026-04-14 — `stop_app()` — exit current app
|
||||||
- [ ] `key()` — send ECP keypress
|
- [x] 2026-04-14 — `key()` — send ECP keypress
|
||||||
- [ ] `sequence()` — send multiple keypresses
|
- [x] 2026-04-14 — `sequence()` — send multiple keypresses
|
||||||
- [ ] `state()` — query power state and active app
|
- [x] 2026-04-14 — `state()` — query power state and active app
|
||||||
- [ ] `dev_install()` — zip upload via ECP dev mode
|
- [ ] `dev_install()` — zip upload via ECP dev mode
|
||||||
- [ ] `dev_reload()` — reload sideloaded app
|
- [x] 2026-04-14 — `dev_reload()` — reload the sideloaded app via `launch/dev`
|
||||||
- [ ] `dev_logs()` — fetch dev logs
|
- [ ] `dev_logs()` — fetch dev logs
|
||||||
- [ ] Key translation table (TvKey → Roku ECP key string)
|
- [x] 2026-04-14 — Key translation table (TvKey → Roku ECP key string)
|
||||||
- [ ] Manual integration test against real Roku device
|
- [ ] Manual integration test against real Roku device
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -155,6 +155,7 @@ out of scope until Milestone 6 is complete and stable.
|
|||||||
- [x] 2026-04-14 — Create module skeleton and placeholder docs layout
|
- [x] 2026-04-14 — Create module skeleton and placeholder docs layout
|
||||||
- [x] 2026-04-14 — Define the adapter contract and core shared data types
|
- [x] 2026-04-14 — Define the adapter contract and core shared data types
|
||||||
- [x] 2026-04-14 — Compile the project cleanly with `cargo build`
|
- [x] 2026-04-14 — Compile the project cleanly with `cargo build`
|
||||||
|
- [x] 2026-04-14 — Add Roku ECP discovery, input, app, and state adapter support with unit tests
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+511
-18
@@ -1,42 +1,535 @@
|
|||||||
use super::{AppInfo, Device, DeviceInfo, DeviceState, Result, TvAdapter, TvError, TvKey};
|
use std::{
|
||||||
|
collections::{BTreeMap, BTreeSet},
|
||||||
|
net::IpAddr,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
/// The Roku ECP adapter placeholder for the foundation milestone.
|
use chrono::Utc;
|
||||||
#[derive(Debug, Clone, Default)]
|
use reqwest::{Client, StatusCode, Url};
|
||||||
pub struct RokuAdapter;
|
use roxmltree::Document;
|
||||||
|
use tokio::{
|
||||||
|
net::UdpSocket,
|
||||||
|
time::{Instant, timeout},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
AppInfo, Device, DeviceInfo, DeviceState, PowerState, Result, TvAdapter, TvError, TvKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROKU_ECP_DISCOVERY_ADDR: &str = "239.255.255.250:1900";
|
||||||
|
const ROKU_ECP_DISCOVERY_REQUEST: &str = concat!(
|
||||||
|
"M-SEARCH * HTTP/1.1\r\n",
|
||||||
|
"Host: 239.255.255.250:1900\r\n",
|
||||||
|
"Man: \"ssdp:discover\"\r\n",
|
||||||
|
"ST: roku:ecp\r\n",
|
||||||
|
"\r\n",
|
||||||
|
);
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 5;
|
||||||
|
const DEFAULT_DISCOVERY_TIMEOUT_SECS: u64 = 3;
|
||||||
|
|
||||||
|
/// A Roku ECP adapter backed by SSDP and HTTP requests.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RokuAdapter {
|
||||||
|
client: Client,
|
||||||
|
request_timeout: Duration,
|
||||||
|
discovery_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
impl RokuAdapter {
|
impl RokuAdapter {
|
||||||
/// Create a new Roku adapter instance.
|
/// Create a new Roku adapter instance.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
||||||
|
discovery_timeout: Duration::from_secs(DEFAULT_DISCOVERY_TIMEOUT_SECS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a Roku adapter with custom request timeouts.
|
||||||
|
pub fn with_timeouts(request_timeout: Duration, discovery_timeout: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
request_timeout,
|
||||||
|
discovery_timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_text(&self, url: Url) -> Result<String> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(url.clone())
|
||||||
|
.timeout(self.request_timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| TvError::Transport(format!("GET {url} failed: {error}")))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(TvError::Transport(format!(
|
||||||
|
"GET {url} failed with status {status}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|error| TvError::Transport(format!("reading {url} failed: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_optional_text(&self, url: Url) -> Result<Option<String>> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(url.clone())
|
||||||
|
.timeout(self.request_timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| TvError::Transport(format!("GET {url} failed: {error}")))?;
|
||||||
|
|
||||||
|
if response.status() == StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(TvError::Transport(format!(
|
||||||
|
"GET {url} failed with status {status}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|error| TvError::Transport(format!("reading {url} failed: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_empty(&self, url: Url) -> Result<()> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(url.clone())
|
||||||
|
.timeout(self.request_timeout)
|
||||||
|
.body(Vec::new())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| TvError::Transport(format!("POST {url} failed: {error}")))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(TvError::Transport(format!(
|
||||||
|
"POST {url} failed with status {status}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_base_url(device: &Device) -> Result<Url> {
|
||||||
|
let host = match device.address {
|
||||||
|
IpAddr::V4(address) => address.to_string(),
|
||||||
|
IpAddr::V6(address) => format!("[{address}]"),
|
||||||
|
};
|
||||||
|
Url::parse(&format!("http://{host}:{}/", device.port))
|
||||||
|
.map_err(|error| TvError::Transport(format!("invalid device URL: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_url(base_url: &Url, path: &str) -> Result<Url> {
|
||||||
|
base_url
|
||||||
|
.join(path)
|
||||||
|
.map_err(|error| TvError::Transport(format!("invalid Roku endpoint {path}: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn device_text(&self, device: &Device, path: &str) -> Result<String> {
|
||||||
|
let base_url = Self::device_base_url(device)?;
|
||||||
|
let url = Self::join_url(&base_url, path)?;
|
||||||
|
self.get_text(url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn device_optional_text(&self, device: &Device, path: &str) -> Result<Option<String>> {
|
||||||
|
let base_url = Self::device_base_url(device)?;
|
||||||
|
let url = Self::join_url(&base_url, path)?;
|
||||||
|
self.get_optional_text(url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn device_post(&self, device: &Device, path: &str) -> Result<()> {
|
||||||
|
let base_url = Self::device_base_url(device)?;
|
||||||
|
let url = Self::join_url(&base_url, path)?;
|
||||||
|
self.post_empty(url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_device_info(&self, base_url: &Url) -> Result<RokuDeviceInfo> {
|
||||||
|
let url = Self::join_url(base_url, "query/device-info")?;
|
||||||
|
let xml = self.get_text(url).await?;
|
||||||
|
parse_device_info(&xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RokuAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TvAdapter for RokuAdapter {
|
impl TvAdapter for RokuAdapter {
|
||||||
async fn discover(&self) -> Result<Vec<DeviceInfo>> {
|
async fn discover(&self) -> Result<Vec<DeviceInfo>> {
|
||||||
Err(TvError::NotSupported("discover"))
|
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||||
|
socket
|
||||||
|
.send_to(
|
||||||
|
ROKU_ECP_DISCOVERY_REQUEST.as_bytes(),
|
||||||
|
ROKU_ECP_DISCOVERY_ADDR,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let deadline = Instant::now() + self.discovery_timeout;
|
||||||
|
let mut buffer = [0_u8; 2048];
|
||||||
|
let mut locations = BTreeSet::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let now = Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn state(&self, _device: &Device) -> Result<DeviceState> {
|
let remaining = deadline - now;
|
||||||
Err(TvError::NotSupported("state"))
|
match timeout(remaining, socket.recv_from(&mut buffer)).await {
|
||||||
|
Ok(Ok((size, _peer))) => {
|
||||||
|
let response = String::from_utf8_lossy(&buffer[..size]);
|
||||||
|
if let Some(location) = parse_ssdp_location(&response) {
|
||||||
|
locations.insert(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => return Err(TvError::Io(error)),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn launch(&self, _device: &Device, _app: &str) -> Result<()> {
|
let mut devices = BTreeMap::new();
|
||||||
Err(TvError::NotSupported("launch"))
|
for location in locations {
|
||||||
|
let Ok(base_url) = Url::parse(&location) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let info = self.fetch_device_info(&base_url).await?;
|
||||||
|
let address = match base_url
|
||||||
|
.host_str()
|
||||||
|
.and_then(|value| value.parse::<IpAddr>().ok())
|
||||||
|
{
|
||||||
|
Some(address) => address,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let port = base_url.port_or_known_default().unwrap_or(8060);
|
||||||
|
|
||||||
|
devices.insert(
|
||||||
|
format!("{address}:{port}"),
|
||||||
|
DeviceInfo {
|
||||||
|
name: info.display_name(),
|
||||||
|
platform: "roku".to_string(),
|
||||||
|
address,
|
||||||
|
port,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_app(&self, _device: &Device) -> Result<()> {
|
Ok(devices.into_values().collect())
|
||||||
Err(TvError::NotSupported("stop_app"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn key(&self, _device: &Device, _key: TvKey) -> Result<()> {
|
async fn state(&self, device: &Device) -> Result<DeviceState> {
|
||||||
Err(TvError::NotSupported("key"))
|
let info_xml = self.device_text(device, "query/device-info").await?;
|
||||||
|
let info = parse_device_info(&info_xml)?;
|
||||||
|
let active_app_xml = self
|
||||||
|
.device_optional_text(device, "query/active-app")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(DeviceState {
|
||||||
|
device_id: device.id,
|
||||||
|
power: info.power_state,
|
||||||
|
active_app: active_app_xml
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_active_app)
|
||||||
|
.transpose()?
|
||||||
|
.flatten(),
|
||||||
|
volume: None,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sequence(&self, _device: &Device, _keys: Vec<TvKey>) -> Result<()> {
|
async fn launch(&self, device: &Device, app: &str) -> Result<()> {
|
||||||
Err(TvError::NotSupported("sequence"))
|
self.device_post(device, &format!("launch/{app}")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_apps(&self, _device: &Device) -> Result<Vec<AppInfo>> {
|
async fn stop_app(&self, device: &Device) -> Result<()> {
|
||||||
Err(TvError::NotSupported("list_apps"))
|
// Roku exposes a generic app-exit path only for developer workflows, so
|
||||||
|
// returning to Home is the stable user-mode equivalent of stopping the app.
|
||||||
|
self.device_post(device, "keypress/Home").await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn key(&self, device: &Device, key: TvKey) -> Result<()> {
|
||||||
|
for path in roku_key_paths(&key)? {
|
||||||
|
self.device_post(device, &format!("keypress/{path}"))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sequence(&self, device: &Device, keys: Vec<TvKey>) -> Result<()> {
|
||||||
|
for key in keys {
|
||||||
|
self.key(device, key).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_apps(&self, device: &Device) -> Result<Vec<AppInfo>> {
|
||||||
|
let xml = self.device_text(device, "query/apps").await?;
|
||||||
|
parse_apps(&xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dev_reload(&self, device: &Device) -> Result<()> {
|
||||||
|
self.device_post(device, "launch/dev").await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct RokuDeviceInfo {
|
||||||
|
user_device_name: Option<String>,
|
||||||
|
model_name: Option<String>,
|
||||||
|
power_state: PowerState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RokuDeviceInfo {
|
||||||
|
fn display_name(&self) -> String {
|
||||||
|
self.user_device_name
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.model_name.clone())
|
||||||
|
.unwrap_or_else(|| "Roku".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_device_info(xml: &str) -> Result<RokuDeviceInfo> {
|
||||||
|
let document = parse_xml_document(xml)?;
|
||||||
|
let root = document.root_element();
|
||||||
|
|
||||||
|
Ok(RokuDeviceInfo {
|
||||||
|
user_device_name: text_at(&root, "user-device-name"),
|
||||||
|
model_name: text_at(&root, "model-name"),
|
||||||
|
power_state: map_power_state(text_at(&root, "power-mode").as_deref()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_apps(xml: &str) -> Result<Vec<AppInfo>> {
|
||||||
|
let document = parse_xml_document(xml)?;
|
||||||
|
let apps = document
|
||||||
|
.descendants()
|
||||||
|
.filter(|node| node.is_element() && node.tag_name().name() == "app")
|
||||||
|
.filter_map(|node| {
|
||||||
|
let platform_id = node.attribute("id")?.trim();
|
||||||
|
if platform_id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = node.text()?.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(AppInfo {
|
||||||
|
id: platform_id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
version: node.attribute("version").map(ToString::to_string),
|
||||||
|
platform_id: platform_id.to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(apps)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_active_app(xml: &str) -> Result<Option<AppInfo>> {
|
||||||
|
let document = parse_xml_document(xml)?;
|
||||||
|
let root = document.root_element();
|
||||||
|
let Some(node) = root.children().find(|child| child.is_element()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(platform_id) = node
|
||||||
|
.attribute("id")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = node.text().unwrap_or_default().trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(AppInfo {
|
||||||
|
id: platform_id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
version: node.attribute("version").map(ToString::to_string),
|
||||||
|
platform_id: platform_id.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_xml_document(xml: &str) -> Result<Document<'_>> {
|
||||||
|
Document::parse(xml)
|
||||||
|
.map_err(|error| TvError::Serialization(format!("invalid Roku XML: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_at(node: &roxmltree::Node<'_, '_>, tag_name: &str) -> Option<String> {
|
||||||
|
node.children()
|
||||||
|
.find(|child| child.is_element() && child.tag_name().name() == tag_name)
|
||||||
|
.and_then(|child| child.text())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ssdp_location(response: &str) -> Option<String> {
|
||||||
|
response.lines().find_map(|line| {
|
||||||
|
let (name, value) = line.split_once(':')?;
|
||||||
|
if !name.trim().eq_ignore_ascii_case("location") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(value.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_power_state(value: Option<&str>) -> PowerState {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return PowerState::Unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
let normalized = value.trim().to_ascii_lowercase();
|
||||||
|
if normalized.contains("on") {
|
||||||
|
PowerState::On
|
||||||
|
} else if normalized.contains("off")
|
||||||
|
|| normalized.contains("standby")
|
||||||
|
|| normalized.contains("suspend")
|
||||||
|
{
|
||||||
|
PowerState::Off
|
||||||
|
} else {
|
||||||
|
PowerState::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roku_key_paths(key: &TvKey) -> Result<Vec<String>> {
|
||||||
|
let path = match key {
|
||||||
|
TvKey::Home => return Ok(vec!["Home".to_string()]),
|
||||||
|
TvKey::Back => return Ok(vec!["Back".to_string()]),
|
||||||
|
TvKey::Up => return Ok(vec!["Up".to_string()]),
|
||||||
|
TvKey::Down => return Ok(vec!["Down".to_string()]),
|
||||||
|
TvKey::Left => return Ok(vec!["Left".to_string()]),
|
||||||
|
TvKey::Right => return Ok(vec!["Right".to_string()]),
|
||||||
|
TvKey::Select => return Ok(vec!["Select".to_string()]),
|
||||||
|
TvKey::Play | TvKey::Pause | TvKey::PlayPause => return Ok(vec!["Play".to_string()]),
|
||||||
|
TvKey::Rewind => return Ok(vec!["Rev".to_string()]),
|
||||||
|
TvKey::FastForward => return Ok(vec!["Fwd".to_string()]),
|
||||||
|
TvKey::Replay => return Ok(vec!["InstantReplay".to_string()]),
|
||||||
|
TvKey::ChannelUp => return Ok(vec!["ChannelUp".to_string()]),
|
||||||
|
TvKey::ChannelDown => return Ok(vec!["ChannelDown".to_string()]),
|
||||||
|
TvKey::VolumeUp => return Ok(vec!["VolumeUp".to_string()]),
|
||||||
|
TvKey::VolumeDown => return Ok(vec!["VolumeDown".to_string()]),
|
||||||
|
TvKey::Mute => return Ok(vec!["VolumeMute".to_string()]),
|
||||||
|
TvKey::PowerOff => return Ok(vec!["PowerOff".to_string()]),
|
||||||
|
TvKey::InputHdmi1 => return Ok(vec!["InputHDMI1".to_string()]),
|
||||||
|
TvKey::InputHdmi2 => return Ok(vec!["InputHDMI2".to_string()]),
|
||||||
|
TvKey::InputHdmi3 => return Ok(vec!["InputHDMI3".to_string()]),
|
||||||
|
TvKey::InputHdmi4 => return Ok(vec!["InputHDMI4".to_string()]),
|
||||||
|
TvKey::InputAv => return Ok(vec!["InputAV1".to_string()]),
|
||||||
|
TvKey::InputTuner => return Ok(vec!["InputTuner".to_string()]),
|
||||||
|
TvKey::Search => return Ok(vec!["Search".to_string()]),
|
||||||
|
TvKey::Info => return Ok(vec!["Info".to_string()]),
|
||||||
|
TvKey::Literal(value) => {
|
||||||
|
return Ok(value
|
||||||
|
.chars()
|
||||||
|
.map(|character| format!("Lit_{}", urlencoding::encode(&character.to_string())))
|
||||||
|
.collect());
|
||||||
|
}
|
||||||
|
TvKey::Stop => "stop",
|
||||||
|
TvKey::Skip => "skip",
|
||||||
|
TvKey::Power => "power",
|
||||||
|
TvKey::PowerOn => "power-on",
|
||||||
|
TvKey::Options => "options",
|
||||||
|
};
|
||||||
|
|
||||||
|
Err(TvError::InvalidKey(path.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ssdp_location_case_insensitively() {
|
||||||
|
let response = "HTTP/1.1 200 OK\r\nLOCATION: http://192.168.1.42:8060/\r\nUSN: uuid:roku:ecp:1234\r\n\r\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_ssdp_location(response).as_deref(),
|
||||||
|
Some("http://192.168.1.42:8060/")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_device_info_power_and_name() {
|
||||||
|
let xml = r#"
|
||||||
|
<device-info>
|
||||||
|
<user-device-name>Living Room Roku</user-device-name>
|
||||||
|
<model-name>Roku Ultra</model-name>
|
||||||
|
<power-mode>PowerOn</power-mode>
|
||||||
|
</device-info>
|
||||||
|
"#;
|
||||||
|
let info = parse_device_info(xml).expect("device info should parse");
|
||||||
|
assert_eq!(info.display_name(), "Living Room Roku");
|
||||||
|
assert_eq!(info.power_state, PowerState::On);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_app_lists() {
|
||||||
|
let xml = r#"
|
||||||
|
<apps>
|
||||||
|
<app id="12" type="appl" version="4.1.218">Netflix</app>
|
||||||
|
<app id="13" type="appl">YouTube</app>
|
||||||
|
</apps>
|
||||||
|
"#;
|
||||||
|
let apps = parse_apps(xml).expect("apps should parse");
|
||||||
|
assert_eq!(apps.len(), 2);
|
||||||
|
assert_eq!(apps[0].platform_id, "12");
|
||||||
|
assert_eq!(apps[0].name, "Netflix");
|
||||||
|
assert_eq!(apps[0].version.as_deref(), Some("4.1.218"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_active_app() {
|
||||||
|
let xml = r#"
|
||||||
|
<active-app>
|
||||||
|
<app id="12" type="appl" version="4.1.218">Netflix</app>
|
||||||
|
</active-app>
|
||||||
|
"#;
|
||||||
|
let app = parse_active_app(xml)
|
||||||
|
.expect("active app XML should parse")
|
||||||
|
.expect("active app should exist");
|
||||||
|
assert_eq!(app.id, "12");
|
||||||
|
assert_eq!(app.name, "Netflix");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_supported_keys_and_literals() {
|
||||||
|
assert_eq!(
|
||||||
|
roku_key_paths(&TvKey::FastForward).expect("key should map"),
|
||||||
|
vec!["Fwd"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
roku_key_paths(&TvKey::Literal("a ".to_string())).expect("literal should map"),
|
||||||
|
vec!["Lit_a", "Lit_%20"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_keys_without_a_documented_roku_mapping() {
|
||||||
|
let error = roku_key_paths(&TvKey::PowerOn).expect_err("PowerOn should not map");
|
||||||
|
assert!(matches!(error, TvError::InvalidKey(key) if key == "power-on"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user