795aa2f713
Remove the internal daemon subcommand from the public CLI surface, start the daemon via an internal env trigger, and ensure generated completions/help never expose internal entrypoints. Also finish the pending observation cleanups and docs updates, including config/key deduplication, registry matching cleanup, and remaining roadmap/project map staleness fixes.
474 lines
16 KiB
Rust
474 lines
16 KiB
Rust
use std::{net::IpAddr, path::PathBuf};
|
|
|
|
use anyhow::Context;
|
|
use chrono::Utc;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::fs;
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
adapters::{
|
|
AppInfo, Device, DeviceInfo, DeviceState, TvAdapter, TvKey,
|
|
roku::{RokuAdapter, RokuKeyMode},
|
|
},
|
|
daemon::config::TvctlConfig,
|
|
};
|
|
|
|
/// The persisted collection of known devices.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DeviceRegistry {
|
|
path: PathBuf,
|
|
/// All devices currently remembered by the daemon.
|
|
pub devices: Vec<Device>,
|
|
}
|
|
|
|
impl DeviceRegistry {
|
|
/// Load the registry from disk or return an empty registry when absent.
|
|
pub async fn load(path: PathBuf) -> anyhow::Result<Self> {
|
|
let devices = match fs::read_to_string(&path).await {
|
|
Ok(contents) => serde_json::from_str::<Vec<Device>>(&contents).with_context(|| {
|
|
format!("failed to parse device registry at {}", path.display())
|
|
})?,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
|
Err(error) => return Err(error.into()),
|
|
};
|
|
|
|
Ok(Self { path, devices })
|
|
}
|
|
|
|
/// Persist the current registry to disk.
|
|
pub async fn save(&self) -> anyhow::Result<()> {
|
|
if let Some(parent) = self.path.parent() {
|
|
fs::create_dir_all(parent).await?;
|
|
}
|
|
let contents = serde_json::to_string_pretty(&self.devices)?;
|
|
fs::write(&self.path, contents).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Return all known devices.
|
|
pub fn list(&self) -> &[Device] {
|
|
&self.devices
|
|
}
|
|
|
|
/// Upsert discovered devices, preserving UUIDs for known entries.
|
|
pub fn merge_discovered(&mut self, discovered: Vec<DeviceInfo>) -> Vec<Device> {
|
|
discovered
|
|
.into_iter()
|
|
.map(|info| self.upsert_device(info, None))
|
|
.collect()
|
|
}
|
|
|
|
/// Add or update a manually specified device after it has been probed.
|
|
pub fn merge_manual(&mut self, info: DeviceInfo, name: Option<String>) -> Device {
|
|
self.upsert_device(info, name)
|
|
}
|
|
|
|
/// Set the default device by UUID or name.
|
|
pub fn set_default(&mut self, target: &str) -> Option<Device> {
|
|
let selected = self.find(target)?.id;
|
|
let mut selected_device = None;
|
|
|
|
for device in &mut self.devices {
|
|
let is_match = device.id == selected;
|
|
device.is_default = is_match;
|
|
if is_match {
|
|
selected_device = Some(device.clone());
|
|
}
|
|
}
|
|
|
|
selected_device
|
|
}
|
|
|
|
/// Find a device by UUID or case-insensitive name.
|
|
pub fn find(&self, target: &str) -> Option<&Device> {
|
|
let target_uuid = Uuid::parse_str(target).ok();
|
|
let normalized = target.to_ascii_lowercase();
|
|
self.devices.iter().find(|device| {
|
|
target_uuid.map(|uuid| device.id == uuid).unwrap_or(false)
|
|
|| device.name.to_ascii_lowercase() == normalized
|
|
})
|
|
}
|
|
|
|
/// Return the current default device, if any.
|
|
pub fn default_device(&self) -> Option<&Device> {
|
|
self.devices.iter().find(|device| device.is_default)
|
|
}
|
|
|
|
/// Remove a device by UUID or case-insensitive name.
|
|
pub fn remove(&mut self, target: &str) -> Option<Device> {
|
|
let id = self.find(target)?.id;
|
|
let index = self.devices.iter().position(|device| device.id == id)?;
|
|
let removed = self.devices.remove(index);
|
|
self.ensure_default();
|
|
Some(removed)
|
|
}
|
|
|
|
/// Ensure the registry's default marker is valid and singular.
|
|
pub fn ensure_default(&mut self) {
|
|
if self.devices.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let Some(default_index) = self.devices.iter().position(|device| device.is_default) else {
|
|
self.devices[0].is_default = true;
|
|
return;
|
|
};
|
|
|
|
for (index, device) in self.devices.iter_mut().enumerate() {
|
|
device.is_default = index == default_index;
|
|
}
|
|
}
|
|
|
|
fn upsert_device(&mut self, info: DeviceInfo, name: Option<String>) -> Device {
|
|
let DeviceInfo {
|
|
name: original_name,
|
|
platform,
|
|
address,
|
|
port,
|
|
} = info;
|
|
let now = Utc::now();
|
|
|
|
if let Some(device) = self.find_platform_address_mut(&platform, address) {
|
|
device.port = port;
|
|
device.original_name = original_name.clone();
|
|
if let Some(name) = name {
|
|
device.name = name;
|
|
}
|
|
device.last_seen = now;
|
|
return device.clone();
|
|
}
|
|
|
|
let is_default = self.devices.is_empty();
|
|
let device = Device {
|
|
id: Uuid::new_v4(),
|
|
name: name.unwrap_or_else(|| original_name.clone()),
|
|
original_name,
|
|
platform,
|
|
address,
|
|
port,
|
|
is_default,
|
|
discovered_at: now,
|
|
last_seen: now,
|
|
};
|
|
self.devices.push(device.clone());
|
|
self.ensure_default();
|
|
device
|
|
}
|
|
|
|
fn find_platform_address_mut(
|
|
&mut self,
|
|
platform: &str,
|
|
address: IpAddr,
|
|
) -> Option<&mut Device> {
|
|
self.devices
|
|
.iter_mut()
|
|
.find(|device| device.platform == platform && device.address == address)
|
|
}
|
|
}
|
|
|
|
impl Default for DeviceRegistry {
|
|
fn default() -> Self {
|
|
Self {
|
|
path: PathBuf::from("devices.json"),
|
|
devices: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A registry of platform adapters available to the daemon.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct AdapterRegistry {
|
|
roku: RokuAdapter,
|
|
}
|
|
|
|
impl AdapterRegistry {
|
|
/// Build the adapter registry from the loaded daemon config.
|
|
pub fn from_config(config: &TvctlConfig) -> Self {
|
|
let username =
|
|
(!config.dev.roku_username.is_empty()).then(|| config.dev.roku_username.clone());
|
|
let password =
|
|
(!config.dev.roku_password.is_empty()).then(|| config.dev.roku_password.clone());
|
|
let key_mode = RokuKeyMode::from_config(
|
|
&config.remote.roku_key_mode,
|
|
config.remote.roku_press_duration_ms,
|
|
);
|
|
Self {
|
|
roku: RokuAdapter::with_config(username, password, key_mode),
|
|
}
|
|
}
|
|
|
|
/// Return the supported platform names.
|
|
pub fn supported_platforms(&self) -> Vec<&'static str> {
|
|
vec!["roku"]
|
|
}
|
|
|
|
/// Discover candidate devices for one platform.
|
|
pub async fn discover(&self, platform: &str) -> anyhow::Result<Vec<DeviceInfo>> {
|
|
self.with_platform(platform, |roku| Box::pin(roku.discover()))
|
|
.await
|
|
}
|
|
|
|
/// Return true when a platform is supported.
|
|
pub fn supports(&self, platform: &str) -> bool {
|
|
self.supported_platforms().contains(&platform)
|
|
}
|
|
|
|
/// Probe a manually specified device to verify it matches the requested platform.
|
|
pub async fn probe_manual(
|
|
&self,
|
|
platform: &str,
|
|
address: IpAddr,
|
|
port: Option<u16>,
|
|
) -> anyhow::Result<DeviceInfo> {
|
|
self.with_platform(platform, |roku| {
|
|
Box::pin(roku.probe_device(address, port.unwrap_or(8060)))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Return apps from a concrete device using its platform adapter.
|
|
pub async fn list_apps(&self, device: &Device) -> anyhow::Result<Vec<AppInfo>> {
|
|
self.with_device(device, |roku, device| Box::pin(roku.list_apps(device)))
|
|
.await
|
|
}
|
|
|
|
/// Fetch the current state for a concrete device.
|
|
pub async fn state(&self, device: &Device) -> anyhow::Result<DeviceState> {
|
|
self.with_device(device, |roku, device| Box::pin(roku.state(device)))
|
|
.await
|
|
}
|
|
|
|
/// Launch an app on a concrete device.
|
|
pub async fn launch(&self, device: &Device, app: &str) -> anyhow::Result<()> {
|
|
let app = app.to_string();
|
|
self.with_device(device, move |roku, device| {
|
|
Box::pin(async move { roku.launch(device, &app).await })
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Stop the currently running app on a concrete device.
|
|
pub async fn stop_app(&self, device: &Device) -> anyhow::Result<()> {
|
|
self.with_device(device, |roku, device| Box::pin(roku.stop_app(device)))
|
|
.await
|
|
}
|
|
|
|
/// Send a single normalized key to a concrete device.
|
|
pub async fn key(&self, device: &Device, key: TvKey) -> anyhow::Result<()> {
|
|
self.with_device(device, |roku, device| Box::pin(roku.key(device, key)))
|
|
.await
|
|
}
|
|
|
|
/// Send a normalized key sequence to a concrete device.
|
|
pub async fn sequence(&self, device: &Device, keys: Vec<TvKey>) -> anyhow::Result<()> {
|
|
self.with_device(device, |roku, device| Box::pin(roku.sequence(device, keys)))
|
|
.await
|
|
}
|
|
|
|
/// Install a development package on a concrete device.
|
|
pub async fn dev_install(&self, device: &Device, zip: &[u8]) -> anyhow::Result<()> {
|
|
let zip = zip.to_vec();
|
|
self.with_device(device, move |roku, device| {
|
|
Box::pin(async move { roku.dev_install(device, &zip).await })
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Reload the active development package on a concrete device.
|
|
pub async fn dev_reload(&self, device: &Device) -> anyhow::Result<()> {
|
|
self.with_device(device, |roku, device| Box::pin(roku.dev_reload(device)))
|
|
.await
|
|
}
|
|
|
|
/// Fetch development logs from a concrete device.
|
|
pub async fn dev_logs(&self, device: &Device) -> anyhow::Result<Vec<String>> {
|
|
self.with_device(device, |roku, device| Box::pin(roku.dev_logs(device)))
|
|
.await
|
|
}
|
|
|
|
async fn with_platform<T, F>(&self, platform: &str, call: F) -> anyhow::Result<T>
|
|
where
|
|
F: for<'a> FnOnce(
|
|
&'a RokuAdapter,
|
|
) -> std::pin::Pin<
|
|
Box<dyn std::future::Future<Output = crate::adapters::Result<T>> + Send + 'a>,
|
|
>,
|
|
{
|
|
match platform {
|
|
"roku" => Ok(call(&self.roku).await?),
|
|
other => anyhow::bail!("unsupported platform '{other}'"),
|
|
}
|
|
}
|
|
|
|
async fn with_device<T, F>(&self, device: &Device, call: F) -> anyhow::Result<T>
|
|
where
|
|
F: for<'a> FnOnce(
|
|
&'a RokuAdapter,
|
|
&'a Device,
|
|
) -> std::pin::Pin<
|
|
Box<dyn std::future::Future<Output = crate::adapters::Result<T>> + Send + 'a>,
|
|
>,
|
|
{
|
|
match device.platform.as_str() {
|
|
"roku" => Ok(call(&self.roku, device).await?),
|
|
other => anyhow::bail!("unsupported platform '{other}'"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn registry_round_trips_and_preserves_ids() {
|
|
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
|
|
let path = temp_dir.path().join("devices.json");
|
|
let mut registry = DeviceRegistry::load(path.clone())
|
|
.await
|
|
.expect("registry should load");
|
|
|
|
let first = registry.merge_discovered(vec![DeviceInfo {
|
|
name: "Living Room".to_string(),
|
|
platform: "roku".to_string(),
|
|
address: "10.0.0.5".parse().expect("valid ip"),
|
|
port: 8060,
|
|
}]);
|
|
let first_id = first[0].id;
|
|
registry.save().await.expect("registry should save");
|
|
|
|
let mut loaded = DeviceRegistry::load(path)
|
|
.await
|
|
.expect("registry should reload");
|
|
let second = loaded.merge_discovered(vec![DeviceInfo {
|
|
name: "Living Room Roku".to_string(),
|
|
platform: "roku".to_string(),
|
|
address: "10.0.0.5".parse().expect("valid ip"),
|
|
port: 8060,
|
|
}]);
|
|
|
|
assert_eq!(second[0].id, first_id);
|
|
assert_eq!(second[0].original_name, "Living Room Roku");
|
|
}
|
|
|
|
#[test]
|
|
fn set_default_matches_case_insensitive_names() {
|
|
let now = Utc::now();
|
|
let mut registry = DeviceRegistry {
|
|
path: PathBuf::from("devices.json"),
|
|
devices: vec![
|
|
Device {
|
|
id: Uuid::new_v4(),
|
|
name: "Living Room".to_string(),
|
|
original_name: "Living Room".to_string(),
|
|
platform: "roku".to_string(),
|
|
address: "10.0.0.5".parse().expect("valid ip"),
|
|
port: 8060,
|
|
is_default: false,
|
|
discovered_at: now,
|
|
last_seen: now,
|
|
},
|
|
Device {
|
|
id: Uuid::new_v4(),
|
|
name: "Bedroom".to_string(),
|
|
original_name: "Bedroom".to_string(),
|
|
platform: "roku".to_string(),
|
|
address: "10.0.0.6".parse().expect("valid ip"),
|
|
port: 8060,
|
|
is_default: false,
|
|
discovered_at: now,
|
|
last_seen: now,
|
|
},
|
|
],
|
|
};
|
|
|
|
let selected = registry
|
|
.set_default("living room")
|
|
.expect("device should exist");
|
|
assert_eq!(selected.name, "Living Room");
|
|
assert_eq!(
|
|
registry.default_device().map(|device| device.name.as_str()),
|
|
Some("Living Room")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_manual_updates_existing_device_without_replacing_id() {
|
|
let now = Utc::now();
|
|
let id = Uuid::new_v4();
|
|
let mut registry = DeviceRegistry {
|
|
path: PathBuf::from("devices.json"),
|
|
devices: vec![Device {
|
|
id,
|
|
name: "Office".to_string(),
|
|
original_name: "Office Roku".to_string(),
|
|
platform: "roku".to_string(),
|
|
address: "10.0.0.9".parse().expect("valid ip"),
|
|
port: 8060,
|
|
is_default: true,
|
|
discovered_at: now,
|
|
last_seen: now,
|
|
}],
|
|
};
|
|
|
|
let merged = registry.merge_manual(
|
|
DeviceInfo {
|
|
name: "Upstairs Roku".to_string(),
|
|
platform: "roku".to_string(),
|
|
address: "10.0.0.9".parse().expect("valid ip"),
|
|
port: 8061,
|
|
},
|
|
Some("Bedroom".to_string()),
|
|
);
|
|
|
|
assert_eq!(merged.id, id);
|
|
assert_eq!(merged.name, "Bedroom");
|
|
assert_eq!(merged.original_name, "Upstairs Roku");
|
|
assert_eq!(merged.port, 8061);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_promotes_another_default_when_needed() {
|
|
let now = Utc::now();
|
|
let living_room_id = Uuid::new_v4();
|
|
let mut registry = DeviceRegistry {
|
|
path: PathBuf::from("devices.json"),
|
|
devices: vec![
|
|
Device {
|
|
id: living_room_id,
|
|
name: "Living Room".to_string(),
|
|
original_name: "Living Room".to_string(),
|
|
platform: "roku".to_string(),
|
|
address: "10.0.0.5".parse().expect("valid ip"),
|
|
port: 8060,
|
|
is_default: true,
|
|
discovered_at: now,
|
|
last_seen: now,
|
|
},
|
|
Device {
|
|
id: Uuid::new_v4(),
|
|
name: "Bedroom".to_string(),
|
|
original_name: "Bedroom".to_string(),
|
|
platform: "roku".to_string(),
|
|
address: "10.0.0.6".parse().expect("valid ip"),
|
|
port: 8060,
|
|
is_default: false,
|
|
discovered_at: now,
|
|
last_seen: now,
|
|
},
|
|
],
|
|
};
|
|
|
|
let removed = registry
|
|
.remove(&living_room_id.to_string())
|
|
.expect("device should be removed");
|
|
|
|
assert_eq!(removed.name, "Living Room");
|
|
assert_eq!(
|
|
registry.default_device().map(|device| device.name.as_str()),
|
|
Some("Bedroom")
|
|
);
|
|
}
|
|
}
|