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:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod adapters;
|
||||
pub mod api;
|
||||
pub mod cli;
|
||||
pub mod daemon;
|
||||
+1
-8
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user