chore: scaffold tvctl foundation
Set up the Rust crate, baseline module layout, and project docs so the repository matches the design bundle and builds cleanly as a starting point.
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod roku;
|
||||
|
||||
/// The shared result type for adapter operations.
|
||||
pub type Result<T> = std::result::Result<T, TvError>;
|
||||
|
||||
/// A platform adapter capable of controlling one class of TVs.
|
||||
pub trait TvAdapter: Send + Sync {
|
||||
/// Discover candidate devices for this platform.
|
||||
async fn discover(&self) -> Result<Vec<DeviceInfo>>;
|
||||
|
||||
/// Fetch the current state of a known device.
|
||||
async fn state(&self, device: &Device) -> Result<DeviceState>;
|
||||
|
||||
/// Launch an application identified by a normalized or platform app ID.
|
||||
async fn launch(&self, device: &Device, app: &str) -> Result<()>;
|
||||
|
||||
/// Stop the currently running application on the device.
|
||||
async fn stop_app(&self, device: &Device) -> Result<()>;
|
||||
|
||||
/// Send a single normalized keypress to the device.
|
||||
async fn key(&self, device: &Device, key: TvKey) -> Result<()>;
|
||||
|
||||
/// Send a sequence of normalized keypresses to the device.
|
||||
async fn sequence(&self, device: &Device, keys: Vec<TvKey>) -> Result<()>;
|
||||
|
||||
/// Return the apps currently installed on the device.
|
||||
async fn list_apps(&self, device: &Device) -> Result<Vec<AppInfo>>;
|
||||
|
||||
/// Install a development package on the device, if supported.
|
||||
async fn dev_install(&self, _device: &Device, _zip: &[u8]) -> Result<()> {
|
||||
Err(TvError::NotSupported("dev_install"))
|
||||
}
|
||||
|
||||
/// Reload the active development package, if supported.
|
||||
async fn dev_reload(&self, _device: &Device) -> Result<()> {
|
||||
Err(TvError::NotSupported("dev_reload"))
|
||||
}
|
||||
|
||||
/// Fetch development logs from the device, if supported.
|
||||
async fn dev_logs(&self, _device: &Device) -> Result<Vec<String>> {
|
||||
Err(TvError::NotSupported("dev_logs"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Device data returned by discovery before registry assignment.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceInfo {
|
||||
/// The device-reported display name.
|
||||
pub name: String,
|
||||
/// The normalized platform identifier.
|
||||
pub platform: String,
|
||||
/// The IP address used to reach the device.
|
||||
pub address: IpAddr,
|
||||
/// The port used by the platform protocol.
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// A device tracked by the tvctl registry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Device {
|
||||
/// The stable tvctl UUID for the device.
|
||||
pub id: Uuid,
|
||||
/// The user-assigned friendly name.
|
||||
pub name: String,
|
||||
/// The original name reported by the device during discovery.
|
||||
pub original_name: String,
|
||||
/// The normalized platform identifier.
|
||||
pub platform: String,
|
||||
/// The IP address used to reach the device.
|
||||
pub address: IpAddr,
|
||||
/// The port used by the platform protocol.
|
||||
pub port: u16,
|
||||
/// Whether this is the default target device.
|
||||
pub is_default: bool,
|
||||
/// When the device was first discovered by tvctl.
|
||||
pub discovered_at: DateTime<Utc>,
|
||||
/// When the device was most recently seen online.
|
||||
pub last_seen: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A normalized power state for a device.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PowerState {
|
||||
/// The device reports it is on.
|
||||
On,
|
||||
/// The device reports it is off.
|
||||
Off,
|
||||
/// The platform cannot currently determine power state.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Volume information returned from a device state query.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct VolumeInfo {
|
||||
/// The device volume level when available.
|
||||
pub level: u8,
|
||||
/// Whether the device is muted.
|
||||
pub muted: bool,
|
||||
}
|
||||
|
||||
/// The last known state for a device.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceState {
|
||||
/// The UUID of the device this state belongs to.
|
||||
pub device_id: Uuid,
|
||||
/// The normalized power state.
|
||||
pub power: PowerState,
|
||||
/// The currently active application, if known.
|
||||
pub active_app: Option<AppInfo>,
|
||||
/// The currently observed volume state, if available.
|
||||
pub volume: Option<VolumeInfo>,
|
||||
/// When this snapshot was collected.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Metadata about an application installed on a device.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AppInfo {
|
||||
/// The tvctl-normalized app identifier.
|
||||
pub id: String,
|
||||
/// The human-readable app name.
|
||||
pub name: String,
|
||||
/// Optional app version returned by the platform.
|
||||
pub version: Option<String>,
|
||||
/// The raw platform-specific app identifier.
|
||||
pub platform_id: String,
|
||||
}
|
||||
|
||||
/// A normalized key identifier accepted by the CLI and API.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum TvKey {
|
||||
Home,
|
||||
Back,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Select,
|
||||
Play,
|
||||
Pause,
|
||||
PlayPause,
|
||||
Stop,
|
||||
Rewind,
|
||||
FastForward,
|
||||
Replay,
|
||||
Skip,
|
||||
ChannelUp,
|
||||
ChannelDown,
|
||||
VolumeUp,
|
||||
VolumeDown,
|
||||
Mute,
|
||||
Power,
|
||||
PowerOn,
|
||||
PowerOff,
|
||||
InputHdmi1,
|
||||
InputHdmi2,
|
||||
InputHdmi3,
|
||||
InputHdmi4,
|
||||
InputAv,
|
||||
InputTuner,
|
||||
Search,
|
||||
Info,
|
||||
Options,
|
||||
Literal(String),
|
||||
}
|
||||
|
||||
/// A structured error produced by adapter implementations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TvError {
|
||||
/// The adapter does not support the requested feature.
|
||||
#[error("platform does not support {0}")]
|
||||
NotSupported(&'static str),
|
||||
/// The requested device could not be found.
|
||||
#[error("device '{0}' was not found")]
|
||||
DeviceNotFound(String),
|
||||
/// The platform rejected or could not perform an operation.
|
||||
#[error("device '{0}' is currently unavailable")]
|
||||
DeviceUnavailable(String),
|
||||
/// The user supplied a key the platform cannot translate.
|
||||
#[error("invalid key '{0}'")]
|
||||
InvalidKey(String),
|
||||
/// A transport-level request failed.
|
||||
#[error("transport error: {0}")]
|
||||
Transport(String),
|
||||
/// Configuration loading or validation failed.
|
||||
#[error("configuration error: {0}")]
|
||||
Config(String),
|
||||
/// A serialization boundary failed.
|
||||
#[error("serialization error: {0}")]
|
||||
Serialization(String),
|
||||
/// An I/O operation failed.
|
||||
#[error("i/o error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use super::{AppInfo, Device, DeviceInfo, DeviceState, Result, TvAdapter, TvError, TvKey};
|
||||
|
||||
/// The Roku ECP adapter placeholder for the foundation milestone.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RokuAdapter;
|
||||
|
||||
impl RokuAdapter {
|
||||
/// Create a new Roku adapter instance.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl TvAdapter for RokuAdapter {
|
||||
async fn discover(&self) -> Result<Vec<DeviceInfo>> {
|
||||
Err(TvError::NotSupported("discover"))
|
||||
}
|
||||
|
||||
async fn state(&self, _device: &Device) -> Result<DeviceState> {
|
||||
Err(TvError::NotSupported("state"))
|
||||
}
|
||||
|
||||
async fn launch(&self, _device: &Device, _app: &str) -> Result<()> {
|
||||
Err(TvError::NotSupported("launch"))
|
||||
}
|
||||
|
||||
async fn stop_app(&self, _device: &Device) -> Result<()> {
|
||||
Err(TvError::NotSupported("stop_app"))
|
||||
}
|
||||
|
||||
async fn key(&self, _device: &Device, _key: TvKey) -> Result<()> {
|
||||
Err(TvError::NotSupported("key"))
|
||||
}
|
||||
|
||||
async fn sequence(&self, _device: &Device, _keys: Vec<TvKey>) -> Result<()> {
|
||||
Err(TvError::NotSupported("sequence"))
|
||||
}
|
||||
|
||||
async fn list_apps(&self, _device: &Device) -> Result<Vec<AppInfo>> {
|
||||
Err(TvError::NotSupported("list_apps"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use axum::Router;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Create the placeholder HTTP router for the tvctl API.
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
/// The standard success envelope for API responses.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SuccessEnvelope<T> {
|
||||
/// Indicates a successful operation.
|
||||
pub ok: bool,
|
||||
/// The payload returned by the request.
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
/// The standard error envelope for API responses.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ErrorEnvelope {
|
||||
/// Indicates the request failed.
|
||||
pub ok: bool,
|
||||
/// The structured error payload.
|
||||
pub error: ApiError,
|
||||
}
|
||||
|
||||
/// A machine-readable API error returned to clients.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ApiError {
|
||||
/// Stable, snake_case error identifier.
|
||||
pub code: String,
|
||||
/// Human-readable summary of the failure.
|
||||
pub message: String,
|
||||
/// Suggested next action for the caller.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
/// The tvctl command-line interface.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "tvctl",
|
||||
version,
|
||||
about = "A local-first daemon and CLI for controlling smart TVs."
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Target a specific device by friendly name or UUID.
|
||||
#[arg(long, global = true)]
|
||||
pub device: Option<String>,
|
||||
|
||||
/// Emit JSON output suitable for scripting.
|
||||
#[arg(long, global = true)]
|
||||
pub json: bool,
|
||||
|
||||
/// The resource-oriented command to execute.
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
||||
/// The top-level resource namespaces exposed by tvctl.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Manage the background daemon.
|
||||
Daemon,
|
||||
/// Discover and manage devices.
|
||||
Device,
|
||||
/// List, launch, and stop applications.
|
||||
App,
|
||||
/// Send remote control input.
|
||||
Remote,
|
||||
/// Query device state.
|
||||
State,
|
||||
/// Use developer-oriented TV features.
|
||||
Dev,
|
||||
/// Inspect and modify tvctl configuration.
|
||||
Config,
|
||||
}
|
||||
|
||||
/// Parse the CLI and return successfully for the repository scaffold.
|
||||
pub async fn run() -> anyhow::Result<()> {
|
||||
let _ = Cli::parse();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::adapters::AppInfo;
|
||||
|
||||
/// A platform-level cache of app metadata discovered from live devices.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppCache {
|
||||
/// The normalized platform identifier for the cache file.
|
||||
pub platform: String,
|
||||
/// The apps currently known for that platform.
|
||||
pub apps: Vec<AppInfo>,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/// Background discovery orchestration for supported TV platforms.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiscoveryService;
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod cache;
|
||||
pub mod discovery;
|
||||
pub mod registry;
|
||||
pub mod state;
|
||||
|
||||
/// The long-lived tvctld process.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Daemon;
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::adapters::Device;
|
||||
|
||||
/// The persisted collection of known devices.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DeviceRegistry {
|
||||
/// All devices currently remembered by the daemon.
|
||||
pub devices: Vec<Device>,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::adapters::DeviceState;
|
||||
|
||||
/// An in-memory cache of the last observed state for each device.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StateCache {
|
||||
/// State entries keyed by device UUID.
|
||||
pub entries: HashMap<Uuid, DeviceState>,
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod adapters;
|
||||
mod api;
|
||||
mod cli;
|
||||
mod daemon;
|
||||
|
||||
/// Launch the tvctl binary entry point.
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive(tracing::Level::INFO.into()),
|
||||
)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
|
||||
cli::run().await
|
||||
}
|
||||
Reference in New Issue
Block a user