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:
+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.
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user