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:
44r0n7
2026-04-14 09:15:17 -04:00
parent 584da2d825
commit ca9a6c509a
5 changed files with 1450 additions and 42 deletions
+511 -18
View File
@@ -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.
#[derive(Debug, Clone, Default)]
pub struct RokuAdapter;
use chrono::Utc;
use reqwest::{Client, StatusCode, Url};
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 {
/// Create a new Roku adapter instance.
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 {
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;
}
let remaining = deadline - now;
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,
}
}
let mut devices = BTreeMap::new();
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,
},
);
}
Ok(devices.into_values().collect())
}
async fn state(&self, _device: &Device) -> Result<DeviceState> {
Err(TvError::NotSupported("state"))
async fn state(&self, device: &Device) -> Result<DeviceState> {
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 launch(&self, _device: &Device, _app: &str) -> Result<()> {
Err(TvError::NotSupported("launch"))
async fn launch(&self, device: &Device, app: &str) -> Result<()> {
self.device_post(device, &format!("launch/{app}")).await
}
async fn stop_app(&self, _device: &Device) -> Result<()> {
Err(TvError::NotSupported("stop_app"))
async fn stop_app(&self, device: &Device) -> Result<()> {
// 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<()> {
Err(TvError::NotSupported("key"))
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<()> {
Err(TvError::NotSupported("sequence"))
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>> {
Err(TvError::NotSupported("list_apps"))
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"));
}
}