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, } impl DeviceRegistry { /// Load the registry from disk or return an empty registry when absent. pub async fn load(path: PathBuf) -> anyhow::Result { let devices = match fs::read_to_string(&path).await { Ok(contents) => serde_json::from_str::>(&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) -> Vec { 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) -> Device { self.upsert_device(info, name) } /// Set the default device by UUID or name. pub fn set_default(&mut self, target: &str) -> Option { 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 { 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) -> 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> { 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, ) -> anyhow::Result { 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> { 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 { 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) -> 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> { self.with_device(device, |roku, device| Box::pin(roku.dev_logs(device))) .await } async fn with_platform(&self, platform: &str, call: F) -> anyhow::Result where F: for<'a> FnOnce( &'a RokuAdapter, ) -> std::pin::Pin< Box> + Send + 'a>, >, { match platform { "roku" => Ok(call(&self.roku).await?), other => anyhow::bail!("unsupported platform '{other}'"), } } async fn with_device(&self, device: &Device, call: F) -> anyhow::Result where F: for<'a> FnOnce( &'a RokuAdapter, &'a Device, ) -> std::pin::Pin< Box> + 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") ); } }