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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md5"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -1618,6 +1624,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"md5",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"roxmltree",
|
"roxmltree",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ anyhow = "1.0"
|
|||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
md5 = "0.7"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "multipart", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "multipart", "rustls-tls"] }
|
||||||
roxmltree = "0.20"
|
roxmltree = "0.20"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
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
|
## 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)
|
**Platform v1:** Roku only (via ECP HTTP API)
|
||||||
**Language:** Rust
|
**Language:** Rust
|
||||||
**Crate type:** Binary (single binary distribution target)
|
**Crate type:** Binary (single binary distribution target)
|
||||||
@@ -43,6 +43,7 @@ tvctl/
|
|||||||
│ ├── API.md ← HTTP API specification (detailed)
|
│ ├── API.md ← HTTP API specification (detailed)
|
||||||
│ └── ADAPTER.md ← Adapter trait spec and implementation guide
|
│ └── ADAPTER.md ← Adapter trait spec and implementation guide
|
||||||
├── src/
|
├── src/
|
||||||
|
│ ├── lib.rs ← Library surface for shared modules and integration tests
|
||||||
│ ├── main.rs ← Binary entry point and runtime bootstrap
|
│ ├── main.rs ← Binary entry point and runtime bootstrap
|
||||||
│ ├── cli/ ← CLI layer (clap-based scaffold)
|
│ ├── cli/ ← CLI layer (clap-based scaffold)
|
||||||
│ │ └── mod.rs
|
│ │ └── mod.rs
|
||||||
@@ -58,6 +59,8 @@ tvctl/
|
|||||||
│ ├── mod.rs ← Adapter trait definition and core data shapes
|
│ ├── mod.rs ← Adapter trait definition and core data shapes
|
||||||
│ └── roku/ ← Roku ECP adapter implementation
|
│ └── roku/ ← Roku ECP adapter implementation
|
||||||
│ └── mod.rs
|
│ └── mod.rs
|
||||||
|
├── tests/
|
||||||
|
│ └── roku_live.rs ← Live Roku integration tests gated by env vars
|
||||||
└── cache/ ← Runtime cache (gitignored)
|
└── cache/ ← Runtime cache (gitignored)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -335,7 +338,6 @@ enabled = true
|
|||||||
- Daemon runtime, socket transport, and persistence logic
|
- Daemon runtime, socket transport, and persistence logic
|
||||||
- HTTP route handlers and request validation
|
- HTTP route handlers and request validation
|
||||||
- Real CLI command handling beyond skeleton parsing
|
- Real CLI command handling beyond skeleton parsing
|
||||||
- Integration and hardware validation coverage
|
|
||||||
- CI/CD configuration
|
- CI/CD configuration
|
||||||
- Release/packaging
|
- Release/packaging
|
||||||
|
|
||||||
|
|||||||
+8
-6
@@ -7,14 +7,14 @@
|
|||||||
|
|
||||||
## Current Focus
|
## Current Focus
|
||||||
|
|
||||||
**Milestone 2 — Roku Adapter**
|
**Milestone 3 — Daemon Core**
|
||||||
Foundation scaffold is complete. Begin platform implementation work.
|
Roku adapter work is complete. Begin daemon runtime and persistence wiring.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## In Progress
|
## 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 — `key()` — send ECP keypress
|
||||||
- [x] 2026-04-14 — `sequence()` — send multiple keypresses
|
- [x] 2026-04-14 — `sequence()` — send multiple keypresses
|
||||||
- [x] 2026-04-14 — `state()` — query power state and active app
|
- [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`
|
- [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)
|
- [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 — Define the adapter contract and core shared data types
|
||||||
- [x] 2026-04-14 — Compile the project cleanly with `cargo build`
|
- [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 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>;
|
pub type Result<T> = std::result::Result<T, TvError>;
|
||||||
|
|
||||||
/// A platform adapter capable of controlling one class of TVs.
|
/// A platform adapter capable of controlling one class of TVs.
|
||||||
|
#[allow(async_fn_in_trait)]
|
||||||
pub trait TvAdapter: Send + Sync {
|
pub trait TvAdapter: Send + Sync {
|
||||||
/// Discover candidate devices for this platform.
|
/// Discover candidate devices for this platform.
|
||||||
async fn discover(&self) -> Result<Vec<DeviceInfo>>;
|
async fn discover(&self) -> Result<Vec<DeviceInfo>>;
|
||||||
|
|||||||
+274
-1
@@ -1,16 +1,20 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
|
env,
|
||||||
net::IpAddr,
|
net::IpAddr,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use reqwest::{Client, StatusCode, Url};
|
use reqwest::{Client, Method, StatusCode, Url, header::WWW_AUTHENTICATE, multipart};
|
||||||
use roxmltree::Document;
|
use roxmltree::Document;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
|
io::AsyncReadExt,
|
||||||
|
net::TcpStream,
|
||||||
net::UdpSocket,
|
net::UdpSocket,
|
||||||
time::{Instant, timeout},
|
time::{Instant, timeout},
|
||||||
};
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AppInfo, Device, DeviceInfo, DeviceState, PowerState, Result, TvAdapter, TvError, TvKey,
|
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_REQUEST_TIMEOUT_SECS: u64 = 5;
|
||||||
const DEFAULT_DISCOVERY_TIMEOUT_SECS: u64 = 3;
|
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.
|
/// A Roku ECP adapter backed by SSDP and HTTP requests.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -33,6 +40,7 @@ pub struct RokuAdapter {
|
|||||||
client: Client,
|
client: Client,
|
||||||
request_timeout: Duration,
|
request_timeout: Duration,
|
||||||
discovery_timeout: Duration,
|
discovery_timeout: Duration,
|
||||||
|
dev_log_window: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RokuAdapter {
|
impl RokuAdapter {
|
||||||
@@ -42,6 +50,7 @@ impl RokuAdapter {
|
|||||||
client: Client::new(),
|
client: Client::new(),
|
||||||
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
||||||
discovery_timeout: Duration::from_secs(DEFAULT_DISCOVERY_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(),
|
client: Client::new(),
|
||||||
request_timeout,
|
request_timeout,
|
||||||
discovery_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?;
|
let xml = self.get_text(url).await?;
|
||||||
parse_device_info(&xml)
|
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 {
|
impl Default for RokuAdapter {
|
||||||
@@ -285,6 +464,38 @@ impl TvAdapter for RokuAdapter {
|
|||||||
async fn dev_reload(&self, device: &Device) -> Result<()> {
|
async fn dev_reload(&self, device: &Device) -> Result<()> {
|
||||||
self.device_post(device, "launch/dev").await
|
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)]
|
#[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 {
|
fn map_power_state(value: Option<&str>) -> PowerState {
|
||||||
let Some(value) = value else {
|
let Some(value) = value else {
|
||||||
return PowerState::Unknown;
|
return PowerState::Unknown;
|
||||||
@@ -459,6 +682,46 @@ fn roku_key_paths(key: &TvKey) -> Result<Vec<String>> {
|
|||||||
Err(TvError::InvalidKey(path.to_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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -532,4 +795,14 @@ mod tests {
|
|||||||
let error = roku_key_paths(&TvKey::PowerOn).expect_err("PowerOn should not map");
|
let error = roku_key_paths(&TvKey::PowerOn).expect_err("PowerOn should not map");
|
||||||
assert!(matches!(error, TvError::InvalidKey(key) if key == "power-on"));
|
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.
|
/// Launch the tvctl binary entry point.
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
@@ -17,5 +10,5 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.compact()
|
.compact()
|
||||||
.init();
|
.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