diff --git a/Cargo.lock b/Cargo.lock index 0489068..42771e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index ea627d2..5d8193e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 67ffa86..0170e35 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 979df65..167d41b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,14 +7,14 @@ ## Current Focus -**Milestone 2 — Roku Adapter** -Foundation scaffold is complete. Begin platform implementation work. +**Milestone 3 — Daemon 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 --- diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index a689169..d102e09 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -11,6 +11,7 @@ pub mod roku; pub type Result = std::result::Result; /// 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>; diff --git a/src/adapters/roku/mod.rs b/src/adapters/roku/mod.rs index 2cf244f..ae1c5c9 100644 --- a/src/adapters/roku/mod.rs +++ b/src/adapters/roku/mod.rs @@ -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 { + 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 { + 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 { + 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> { + 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> { + self.debugger_lines(device).await + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -398,6 +609,18 @@ fn parse_ssdp_location(response: &str) -> Option { }) } +fn extract_first_message(body: &str, prefix: &str) -> Option { + body.lines() + .map(str::trim) + .find(|line| line.contains(prefix)) + .map(|line| { + line.replace("", "") + .replace("", "") + .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> { Err(TvError::InvalidKey(path.to_string())) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct DigestChallenge { + realm: String, + nonce: String, + qop: String, + opaque: Option, + algorithm: Option, +} + +impl DigestChallenge { + fn parse(header: &str) -> Result { + 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"); + } } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..71c30e5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +#![allow(dead_code)] + +pub mod adapters; +pub mod api; +pub mod cli; +pub mod daemon; diff --git a/src/main.rs b/src/main.rs index 0ebd0c7..d93769b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 } diff --git a/tests/roku_live.rs b/tests/roku_live.rs new file mode 100644 index 0000000..38b3276 --- /dev/null +++ b/tests/roku_live.rs @@ -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> = OnceLock::new(); + +fn live_target_ip() -> Option { + env::var("TVCTL_LIVE_ROKU_IP") + .ok() + .and_then(|value| value.parse::().ok()) +} + +fn live_expected_name() -> Option { + env::var("TVCTL_LIVE_ROKU_NAME").ok() +} + +fn live_dev_password() -> Option { + 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 { + 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 +}