feat: complete daemon core milestone
Finish Milestone 3 with persisted config, socket IPC, registry CRUD, periodic discovery, manual add, and app-cache refresh support.
This commit is contained in:
+390
-2
@@ -1,8 +1,396 @@
|
||||
use crate::adapters::Device;
|
||||
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::{Device, DeviceInfo, TvAdapter, roku::RokuAdapter};
|
||||
|
||||
/// The persisted collection of known devices.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[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 index = self
|
||||
.devices
|
||||
.iter()
|
||||
.position(|device| matches_target(device, target))?;
|
||||
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)]
|
||||
pub struct AdapterRegistry {
|
||||
roku: RokuAdapter,
|
||||
}
|
||||
|
||||
impl Default for AdapterRegistry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
roku: RokuAdapter::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdapterRegistry {
|
||||
/// 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>> {
|
||||
match platform {
|
||||
"roku" => Ok(self.roku.discover().await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
match platform {
|
||||
"roku" => Ok(self
|
||||
.roku
|
||||
.probe_device(address, port.unwrap_or(8060))
|
||||
.await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return apps from a concrete device using its platform adapter.
|
||||
pub async fn list_apps(
|
||||
&self,
|
||||
device: &Device,
|
||||
) -> anyhow::Result<Vec<crate::adapters::AppInfo>> {
|
||||
match device.platform.as_str() {
|
||||
"roku" => Ok(self.roku.list_apps(device).await?),
|
||||
other => anyhow::bail!("unsupported platform '{other}'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_target(device: &Device, target: &str) -> bool {
|
||||
let target_uuid = Uuid::parse_str(target).ok();
|
||||
let normalized = target.to_ascii_lowercase();
|
||||
target_uuid.map(|uuid| device.id == uuid).unwrap_or(false)
|
||||
|| device.name.to_ascii_lowercase() == normalized
|
||||
}
|
||||
|
||||
#[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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user