feat: finish Roku adapter dev-mode support

Add digest-auth sideload installs, debugger log capture, and live Roku
integration coverage so the full Roku milestone is validated on hardware.
This commit is contained in:
44r0n7
2026-04-14 09:35:26 -04:00
parent ca9a6c509a
commit 642fa716d1
9 changed files with 530 additions and 17 deletions
Generated
+7
View File
@@ -785,6 +785,12 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]]
name = "memchr"
version = "2.8.0"
@@ -1618,6 +1624,7 @@ dependencies = [
"axum",
"chrono",
"clap",
"md5",
"reqwest",
"roxmltree",
"serde",
+1
View File
@@ -8,6 +8,7 @@ anyhow = "1.0"
axum = "0.8"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.5", features = ["derive"] }
md5 = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "multipart", "rustls-tls"] }
roxmltree = "0.20"
serde = { version = "1.0", features = ["derive"] }
+4 -2
View File
@@ -19,7 +19,7 @@ script and control smart TVs through a stable, brand-agnostic API.
## Project Status
**Phase:** Milestone 2 in progress. Core Roku ECP support exists; daemon and CLI wiring are still pending.
**Phase:** Milestone 2 complete. Roku adapter is live-validated; daemon and CLI wiring are next.
**Platform v1:** Roku only (via ECP HTTP API)
**Language:** Rust
**Crate type:** Binary (single binary distribution target)
@@ -43,6 +43,7 @@ tvctl/
│ ├── API.md ← HTTP API specification (detailed)
│ └── ADAPTER.md ← Adapter trait spec and implementation guide
├── src/
│ ├── lib.rs ← Library surface for shared modules and integration tests
│ ├── main.rs ← Binary entry point and runtime bootstrap
│ ├── cli/ ← CLI layer (clap-based scaffold)
│ │ └── mod.rs
@@ -58,6 +59,8 @@ tvctl/
│ ├── mod.rs ← Adapter trait definition and core data shapes
│ └── roku/ ← Roku ECP adapter implementation
│ └── mod.rs
├── tests/
│ └── roku_live.rs ← Live Roku integration tests gated by env vars
└── cache/ ← Runtime cache (gitignored)
```
@@ -335,7 +338,6 @@ enabled = true
- Daemon runtime, socket transport, and persistence logic
- HTTP route handlers and request validation
- Real CLI command handling beyond skeleton parsing
- Integration and hardware validation coverage
- CI/CD configuration
- Release/packaging
+8 -6
View File
@@ -7,14 +7,14 @@
## Current Focus
**Milestone 2Roku Adapter**
Foundation scaffold is complete. Begin platform implementation work.
**Milestone 3Daemon Core**
Roku adapter work is complete. Begin daemon runtime and persistence wiring.
---
## In Progress
- Roku adapter `dev_install()` and `dev_logs()` need developer-web credential handling plus real-device validation
_Nothing in progress right now._
---
@@ -36,11 +36,11 @@ _Goal: Can communicate with a real Roku TV over ECP._
- [x] 2026-04-14 — `key()` — send ECP keypress
- [x] 2026-04-14 — `sequence()` — send multiple keypresses
- [x] 2026-04-14 — `state()` — query power state and active app
- [ ] `dev_install()` — zip upload via ECP dev mode
- [x] 2026-04-14 — `dev_install()` — zip upload via ECP developer web installer with Digest auth
- [x] 2026-04-14 — `dev_reload()` — reload the sideloaded app via `launch/dev`
- [ ] `dev_logs()` — fetch dev logs
- [x] 2026-04-14 — `dev_logs()` — fetch BrightScript debugger output from the live dev socket
- [x] 2026-04-14 — Key translation table (TvKey → Roku ECP key string)
- [ ] Manual integration test against real Roku device
- [x] 2026-04-14 — Automated live validation against `58" Hisense Roku TV` at `10.10.0.136`
---
@@ -156,6 +156,8 @@ out of scope until Milestone 6 is complete and stable.
- [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 — Add Roku ECP discovery, input, app, and state adapter support with unit tests
- [x] 2026-04-14 — Add env-gated live Roku integration tests and validate against the Hisense TV on the LAN
- [x] 2026-04-14 — Implement Roku developer-mode install/log support and validate sideloading on the Hisense TV
---
+1
View File
@@ -11,6 +11,7 @@ pub mod roku;
pub type Result<T> = std::result::Result<T, TvError>;
/// A platform adapter capable of controlling one class of TVs.
#[allow(async_fn_in_trait)]
pub trait TvAdapter: Send + Sync {
/// Discover candidate devices for this platform.
async fn discover(&self) -> Result<Vec<DeviceInfo>>;
+274 -1
View File
@@ -1,16 +1,20 @@
use std::{
collections::{BTreeMap, BTreeSet},
env,
net::IpAddr,
time::Duration,
};
use chrono::Utc;
use reqwest::{Client, StatusCode, Url};
use reqwest::{Client, Method, StatusCode, Url, header::WWW_AUTHENTICATE, multipart};
use roxmltree::Document;
use tokio::{
io::AsyncReadExt,
net::TcpStream,
net::UdpSocket,
time::{Instant, timeout},
};
use uuid::Uuid;
use super::{
AppInfo, Device, DeviceInfo, DeviceState, PowerState, Result, TvAdapter, TvError, TvKey,
@@ -26,6 +30,9 @@ const ROKU_ECP_DISCOVERY_REQUEST: &str = concat!(
);
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 5;
const DEFAULT_DISCOVERY_TIMEOUT_SECS: u64 = 3;
const DEFAULT_DEV_LOG_WINDOW_SECS: u64 = 3;
const ROKU_DEV_WEB_PORT: u16 = 80;
const ROKU_DEV_DEBUG_PORT: u16 = 8085;
/// A Roku ECP adapter backed by SSDP and HTTP requests.
#[derive(Debug, Clone)]
@@ -33,6 +40,7 @@ pub struct RokuAdapter {
client: Client,
request_timeout: Duration,
discovery_timeout: Duration,
dev_log_window: Duration,
}
impl RokuAdapter {
@@ -42,6 +50,7 @@ impl RokuAdapter {
client: Client::new(),
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
discovery_timeout: Duration::from_secs(DEFAULT_DISCOVERY_TIMEOUT_SECS),
dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS),
}
}
@@ -51,6 +60,7 @@ impl RokuAdapter {
client: Client::new(),
request_timeout,
discovery_timeout,
dev_log_window: Duration::from_secs(DEFAULT_DEV_LOG_WINDOW_SECS),
}
}
@@ -161,6 +171,175 @@ impl RokuAdapter {
let xml = self.get_text(url).await?;
parse_device_info(&xml)
}
fn dev_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}:{ROKU_DEV_WEB_PORT}/"))
.map_err(|error| TvError::Transport(format!("invalid Roku developer URL: {error}")))
}
async fn developer_credentials(&self) -> Result<(String, String)> {
let username =
env::var("TVCTL_ROKU_DEV_USERNAME").unwrap_or_else(|_| "rokudev".to_string());
let password = env::var("TVCTL_ROKU_DEV_PASSWORD").map_err(|_| {
TvError::Config("missing TVCTL_ROKU_DEV_PASSWORD for Roku developer mode".to_string())
})?;
Ok((username, password))
}
async fn digest_challenge(&self, url: &Url) -> Result<DigestChallenge> {
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::UNAUTHORIZED {
return Err(TvError::Transport(format!(
"expected digest challenge from {url}, got status {}",
response.status()
)));
}
let header = response
.headers()
.get(WWW_AUTHENTICATE)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| TvError::Transport(format!("missing digest challenge from {url}")))?;
DigestChallenge::parse(header)
}
fn digest_authorization(
challenge: &DigestChallenge,
username: &str,
password: &str,
method: &Method,
uri: &str,
) -> String {
let cnonce = format!("{:x}", md5::compute(Uuid::new_v4().as_bytes()));
let nc = "00000001";
let ha1 = format!(
"{:x}",
md5::compute(format!("{username}:{}:{password}", challenge.realm))
);
let ha2 = format!("{:x}", md5::compute(format!("{}:{uri}", method.as_str())));
let response = format!(
"{:x}",
md5::compute(format!(
"{ha1}:{}:{nc}:{cnonce}:{}:{ha2}",
challenge.nonce, challenge.qop
))
);
let mut parts = vec![
format!("username=\"{username}\""),
format!("realm=\"{}\"", challenge.realm),
format!("nonce=\"{}\"", challenge.nonce),
format!("uri=\"{uri}\""),
format!("response=\"{response}\""),
format!("qop={}", challenge.qop),
format!("nc={nc}"),
format!("cnonce=\"{cnonce}\""),
];
if let Some(opaque) = &challenge.opaque {
parts.push(format!("opaque=\"{opaque}\""));
}
if let Some(algorithm) = &challenge.algorithm {
parts.push(format!("algorithm={algorithm}"));
}
format!("Digest {}", parts.join(", "))
}
async fn developer_post_multipart(
&self,
device: &Device,
path: &str,
form: multipart::Form,
) -> Result<String> {
let (username, password) = self.developer_credentials().await?;
let base_url = Self::dev_base_url(device)?;
let url = Self::join_url(&base_url, path)?;
let challenge = self.digest_challenge(&url).await?;
let uri = format!("/{path}");
let authorization =
Self::digest_authorization(&challenge, &username, &password, &Method::POST, &uri);
let response = self
.client
.post(url.clone())
.timeout(self.request_timeout)
.header("Authorization", authorization)
.multipart(form)
.send()
.await
.map_err(|error| TvError::Transport(format!("POST {url} failed: {error}")))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| TvError::Transport(format!("reading {url} failed: {error}")))?;
if status == StatusCode::UNAUTHORIZED {
return Err(TvError::Transport(
"invalid Roku developer credentials".to_string(),
));
}
Ok(body)
}
async fn debugger_lines(&self, device: &Device) -> Result<Vec<String>> {
let address = (device.address, ROKU_DEV_DEBUG_PORT);
let mut stream = timeout(self.request_timeout, TcpStream::connect(address))
.await
.map_err(|_| {
TvError::Transport(format!(
"connecting to Roku debugger at {} timed out",
device.address
))
})?
.map_err(|error| {
TvError::Transport(format!("connecting to Roku debugger failed: {error}"))
})?;
let deadline = Instant::now() + self.dev_log_window;
let mut buffer = vec![0_u8; 4096];
let mut output = Vec::new();
loop {
let now = Instant::now();
if now >= deadline {
break;
}
let remaining = deadline - now;
match timeout(remaining, stream.read(&mut buffer)).await {
Ok(Ok(0)) => break,
Ok(Ok(size)) => output.extend_from_slice(&buffer[..size]),
Ok(Err(error)) => {
return Err(TvError::Transport(format!(
"reading Roku debugger output failed: {error}"
)));
}
Err(_) => break,
}
}
Ok(String::from_utf8_lossy(&output)
.lines()
.map(str::trim_end)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect())
}
}
impl Default for RokuAdapter {
@@ -285,6 +464,38 @@ impl TvAdapter for RokuAdapter {
async fn dev_reload(&self, device: &Device) -> Result<()> {
self.device_post(device, "launch/dev").await
}
async fn dev_install(&self, device: &Device, zip: &[u8]) -> Result<()> {
let part = multipart::Part::bytes(zip.to_vec())
.file_name("tvctl-dev.zip")
.mime_str("application/zip")
.map_err(|error| {
TvError::Transport(format!("invalid Roku package MIME type: {error}"))
})?;
let form = multipart::Form::new()
.text("mysubmit", "Install")
.part("archive", part);
let body = self
.developer_post_multipart(device, "plugin_install", form)
.await?;
if body.contains("Install Success.") {
return Ok(());
}
if let Some(message) = extract_first_message(&body, "Install Failure:") {
return Err(TvError::Transport(message));
}
Err(TvError::Transport(
"Roku developer install did not report success".to_string(),
))
}
async fn dev_logs(&self, device: &Device) -> Result<Vec<String>> {
self.debugger_lines(device).await
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -398,6 +609,18 @@ fn parse_ssdp_location(response: &str) -> Option<String> {
})
}
fn extract_first_message(body: &str, prefix: &str) -> Option<String> {
body.lines()
.map(str::trim)
.find(|line| line.contains(prefix))
.map(|line| {
line.replace("<font color=\"red\">", "")
.replace("</font>", "")
.trim()
.to_string()
})
}
fn map_power_state(value: Option<&str>) -> PowerState {
let Some(value) = value else {
return PowerState::Unknown;
@@ -459,6 +682,46 @@ fn roku_key_paths(key: &TvKey) -> Result<Vec<String>> {
Err(TvError::InvalidKey(path.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DigestChallenge {
realm: String,
nonce: String,
qop: String,
opaque: Option<String>,
algorithm: Option<String>,
}
impl DigestChallenge {
fn parse(header: &str) -> Result<Self> {
let challenge = header
.strip_prefix("Digest ")
.ok_or_else(|| TvError::Transport(format!("unsupported auth challenge: {header}")))?;
let mut values = BTreeMap::new();
for part in challenge.split(',') {
let (key, value) = part
.trim()
.split_once('=')
.ok_or_else(|| TvError::Transport(format!("invalid digest challenge: {header}")))?;
values.insert(
key.trim().to_string(),
value.trim().trim_matches('"').to_string(),
);
}
Ok(Self {
realm: values
.remove("realm")
.ok_or_else(|| TvError::Transport("digest challenge missing realm".to_string()))?,
nonce: values
.remove("nonce")
.ok_or_else(|| TvError::Transport("digest challenge missing nonce".to_string()))?,
qop: values.remove("qop").unwrap_or_else(|| "auth".to_string()),
opaque: values.remove("opaque"),
algorithm: values.remove("algorithm"),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -532,4 +795,14 @@ mod tests {
let error = roku_key_paths(&TvKey::PowerOn).expect_err("PowerOn should not map");
assert!(matches!(error, TvError::InvalidKey(key) if key == "power-on"));
}
#[test]
fn parses_digest_challenge() {
let challenge =
DigestChallenge::parse("Digest qop=\"auth\", realm=\"rokudev\", nonce=\"1776173348\"")
.expect("challenge should parse");
assert_eq!(challenge.realm, "rokudev");
assert_eq!(challenge.nonce, "1776173348");
assert_eq!(challenge.qop, "auth");
}
}
+6
View File
@@ -0,0 +1,6 @@
#![allow(dead_code)]
pub mod adapters;
pub mod api;
pub mod cli;
pub mod daemon;
+1 -8
View File
@@ -1,10 +1,3 @@
#![allow(dead_code)]
mod adapters;
mod api;
mod cli;
mod daemon;
/// Launch the tvctl binary entry point.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -17,5 +10,5 @@ async fn main() -> anyhow::Result<()> {
.compact()
.init();
cli::run().await
tvctl::cli::run().await
}
+228
View File
@@ -0,0 +1,228 @@
use std::{env, fs, net::IpAddr, process::Command, sync::OnceLock, time::Duration};
use chrono::Utc;
use tokio::time::sleep;
use tvctl::adapters::{Device, DeviceState, PowerState, TvAdapter, roku::RokuAdapter};
use uuid::Uuid;
static LIVE_ROKU_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
fn live_target_ip() -> Option<IpAddr> {
env::var("TVCTL_LIVE_ROKU_IP")
.ok()
.and_then(|value| value.parse::<IpAddr>().ok())
}
fn live_expected_name() -> Option<String> {
env::var("TVCTL_LIVE_ROKU_NAME").ok()
}
fn live_dev_password() -> Option<String> {
env::var("TVCTL_ROKU_DEV_PASSWORD").ok()
}
fn live_device(ip: IpAddr) -> Device {
Device {
id: Uuid::new_v4(),
name: live_expected_name().unwrap_or_else(|| "Live Roku".to_string()),
original_name: live_expected_name().unwrap_or_else(|| "Live Roku".to_string()),
platform: "roku".to_string(),
address: ip,
port: 8060,
is_default: false,
discovered_at: Utc::now(),
last_seen: Utc::now(),
}
}
async fn live_guard() -> tokio::sync::MutexGuard<'static, ()> {
LIVE_ROKU_LOCK
.get_or_init(|| tokio::sync::Mutex::new(()))
.lock()
.await
}
async fn wait_for_active_app(
adapter: &RokuAdapter,
device: &Device,
expected_platform_id: &str,
attempts: usize,
) -> DeviceState {
let mut last_state = adapter.state(device).await.expect("state should succeed");
for _ in 0..attempts {
if last_state
.active_app
.as_ref()
.map(|app| app.platform_id.as_str())
== Some(expected_platform_id)
{
return last_state;
}
sleep(Duration::from_millis(750)).await;
last_state = adapter.state(device).await.expect("state should succeed");
}
last_state
}
#[tokio::test]
async fn live_discover_finds_expected_roku() {
let Some(ip) = live_target_ip() else {
return;
};
let _guard = live_guard().await;
let adapter = RokuAdapter::new();
let devices = adapter.discover().await.expect("discovery should succeed");
let device = devices
.iter()
.find(|device| device.address == ip)
.expect("live Roku should be discovered");
if let Some(expected_name) = live_expected_name() {
assert_eq!(device.name, expected_name);
}
}
#[tokio::test]
async fn live_state_and_apps_are_queryable() {
let Some(ip) = live_target_ip() else {
return;
};
let _guard = live_guard().await;
let adapter = RokuAdapter::new();
let device = live_device(ip);
let state = adapter.state(&device).await.expect("state should succeed");
assert_eq!(state.device_id, device.id);
assert_eq!(state.power, PowerState::On);
let active_app = state.active_app.expect("active app should exist");
assert!(!active_app.name.is_empty());
let apps = adapter
.list_apps(&device)
.await
.expect("app listing should succeed");
assert!(!apps.is_empty());
assert!(apps.iter().any(|app| app.name == "Netflix"));
assert!(apps.iter().any(|app| app.name == "YouTube"));
}
#[tokio::test]
async fn live_key_sequence_and_launch_work() {
let Some(ip) = live_target_ip() else {
return;
};
let _guard = live_guard().await;
let adapter = RokuAdapter::new();
let device = live_device(ip);
adapter
.key(&device, tvctl::adapters::TvKey::Home)
.await
.expect("home key should succeed");
let home_state = wait_for_active_app(&adapter, &device, "562859", 12).await;
let home_app = home_state.active_app.expect("home app should exist");
assert_eq!(home_app.name, "Home");
adapter
.launch(&device, "837")
.await
.expect("YouTube launch should succeed");
let launched_state = wait_for_active_app(&adapter, &device, "837", 12).await;
let launched_app = launched_state
.active_app
.expect("launched app should exist");
assert_eq!(launched_app.platform_id, "837");
adapter
.stop_app(&device)
.await
.expect("stop_app should return to home");
let restored_state = wait_for_active_app(&adapter, &device, "562859", 12).await;
let restored_app = restored_state
.active_app
.expect("restored app should exist");
assert_eq!(restored_app.name, "Home");
}
#[tokio::test]
async fn live_dev_install_reload_and_logs_work() {
let Some(ip) = live_target_ip() else {
return;
};
let Some(_password) = live_dev_password() else {
return;
};
let _guard = live_guard().await;
let adapter = RokuAdapter::new();
let device = live_device(ip);
let package = build_dev_test_zip();
adapter
.dev_install(&device, &package)
.await
.expect("dev install should succeed");
adapter
.dev_reload(&device)
.await
.expect("dev reload should succeed");
let state = wait_for_active_app(&adapter, &device, "dev", 12).await;
let app = state.active_app.expect("dev app should be active");
assert_eq!(app.platform_id, "dev");
let logs = adapter
.dev_logs(&device)
.await
.expect("dev logs should be readable");
let joined = logs.join("\n");
assert!(joined.contains("tvctl dev test starting"));
assert!(joined.contains("tvctl heartbeat"));
adapter
.stop_app(&device)
.await
.expect("stop_app should return to home after dev test");
}
fn build_dev_test_zip() -> Vec<u8> {
let root = env::temp_dir().join(format!("tvctl-roku-live-{}", Uuid::new_v4()));
let source_dir = root.join("source");
fs::create_dir_all(&source_dir).expect("source dir should exist");
fs::write(
root.join("manifest"),
"title=tvctl dev test\nsubtitle=tvctl dev test\nmajor_version=1\nminor_version=0\nbuild_version=3\n",
)
.expect("manifest should write");
fs::write(
source_dir.join("main.brs"),
"sub Main()\n print \"tvctl dev test starting\"\n while true\n print \"tvctl heartbeat\"\n sleep(1000)\n end while\nend sub\n",
)
.expect("main source should write");
let zip_path = root.join("tvctl-dev-test.zip");
let status = Command::new("zip")
.current_dir(&root)
.args([
"-qr",
zip_path.to_str().expect("zip path should be utf-8"),
"manifest",
"source",
])
.status()
.expect("zip command should run");
assert!(status.success(), "zip command should succeed");
let bytes = fs::read(&zip_path).expect("zip output should be readable");
let _ = fs::remove_dir_all(&root);
bytes
}