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:
44r0n7
2026-04-14 09:02:32 -04:00
commit 584da2d825
21 changed files with 3266 additions and 0 deletions
+203
View File
@@ -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),
}
+42
View File
@@ -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"))
}
}
+37
View File
@@ -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>,
}
+47
View File
@@ -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(())
}
+10
View File
@@ -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>,
}
+3
View File
@@ -0,0 +1,3 @@
/// Background discovery orchestration for supported TV platforms.
#[derive(Debug, Clone, Default)]
pub struct DiscoveryService;
+8
View File
@@ -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;
+8
View File
@@ -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>,
}
+12
View File
@@ -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
View File
@@ -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
}