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
+4
View File
@@ -0,0 +1,4 @@
/target/
/cache/
/tvctl-agent-base.zip
.DS_Store
+209
View File
@@ -0,0 +1,209 @@
# AGENT.md
# Instructions for AI Agents Working on tvctl
# Read this file completely before writing any code.
---
## Step 1: Orient Yourself
Before doing anything else, read these files in this order:
1. `PROJECT_MAP.md` — architecture, decisions, data shapes, command tree, API surface
2. `ROADMAP.md` — current milestone, what's done, what's next
3. `README.md` — user-facing documentation (understand what you're building toward)
Do not skip any of these. Do not begin coding until you have read all three.
---
## Step 2: Understand What You Must Not Change
The following decisions are final and were made intentionally during design.
Do not re-open, re-litigate, or silently deviate from them:
- **Resource-verb CLI pattern** — `tvctl device list`, not `tvctl list-devices`
- **Unix socket for CLI↔daemon** — not TCP, not pipes
- **HTTP API for tool builders** — versioned at `/v1/`, loopback-only default
- **Adapter trait** — the exact interface defined in PROJECT_MAP.md
- **TOML config** — not YAML, not JSON
- **kebab-case key names** — `volume-up`, not `VolumeUp`, not `volume_up`
- **JSON response envelope** — `{ "ok": true, "data": {...} }` always
- **Organic app cache** — no pre-populated database, grows from live TV data
- **User-level systemd service** — not system-level, not root
If you believe one of these decisions is wrong, document your concern in a
comment or note and ask for clarification. Do not silently work around them.
---
## Step 3: Understand the Codebase Before Touching It
Before modifying any file:
1. Read the file you are about to change completely
2. Read any files it imports or depends on
3. Understand the data flow through the component you are changing
Do not make changes based on filenames or directory structure alone.
---
## Code Standards
### Rust conventions
- Use `thiserror` for error types
- Use `tokio` for all async runtime
- Use `axum` for HTTP server
- Use `clap` (derive API) for CLI
- Use `serde` + `serde_json` for all serialization
- Use `uuid` crate for UUIDs
- Use `chrono` for timestamps
- Prefer `anyhow` for application-level error propagation
- All public types must have doc comments
### Error handling
- Never use `.unwrap()` in non-test code unless you can prove it cannot fail
- Every error returned to the user (CLI or API) must include a `hint` field
- CLI errors must suggest the next action — not just report what went wrong
- API error `code` values are stable contracts — do not change existing codes
### CLI output
- Human-readable output by default
- `--json` flag must work on every command
- Errors go to stderr, data goes to stdout
- Do not mix human text and JSON in the same output stream
### API
- All endpoints return the standard envelope — no exceptions
- `error.code` values are snake_case strings
- `error.hint` is optional but strongly encouraged
- Never return platform-specific field names at the API surface level
### Help text
Every command must have:
1. A one-line description
2. A short paragraph of context
3. A usage line
4. All subcommands/args listed with descriptions
5. At least two concrete examples
6. A notes section for technical details (if applicable)
---
## File Responsibilities
| File/Directory | Responsibility | Notes |
|----------------|----------------|-------|
| `src/main.rs` | Binary entry point, daemon vs CLI dispatch | Keep thin |
| `src/cli/` | All clap definitions and CLI handlers | No business logic here |
| `src/daemon/` | Daemon lifecycle, routing, services | Core of the application |
| `src/daemon/registry.rs` | Device registry, persistence | Owns devices.json |
| `src/daemon/discovery.rs` | SSDP discovery, polling | Platform-agnostic |
| `src/daemon/cache.rs` | App cache, persistence | Per-platform json files |
| `src/daemon/state.rs` | In-memory state cache | Never persisted |
| `src/api/` | axum HTTP server, route definitions | Thin layer over core |
| `src/adapters/mod.rs` | TvAdapter trait, TvKey, shared types | The contract |
| `src/adapters/roku/` | Roku ECP implementation | Only place Roku logic lives |
The CLI and API layers must contain no business logic. They translate
user input into core calls and translate core results into output.
All logic lives in the daemon and adapter layers.
---
## App Resolution Flow
When a user runs `tvctl app launch netflix` or `POST /v1/devices/{id}/apps/launch`:
```
1. Check per-device installed app list in memory
2. If found → launch directly
3. If not found → check platform cache (roku.apps.json)
4. If found in cache → attempt launch (app might be installed)
5. If launch fails → report "not installed", suggest `tvctl app list`
6. If not in cache at all → fetch live app list from TV
7. Populate platform cache with all returned apps
8. Persist to cache file
9. Retry launch
```
Name matching is case-insensitive. Users can also pass raw platform IDs with `--id`.
---
## Discovery Flow
```
1. tvctld starts
2. If discovery.auto_discover = true → run SSDP scan
3. SSDP returns device IP addresses
4. For each address → instantiate appropriate adapter → call adapter.discover()
5. Adapter returns DeviceInfo
6. Check if device UUID already exists in registry
7. If new → add to registry, use device-reported name as default friendly name
8. If existing → update last_seen timestamp
9. Persist registry to devices.json
```
Manual add bypasses SSDP — takes IP and platform directly.
---
## Updating PROJECT_MAP.md
You MUST update PROJECT_MAP.md when you:
- Add a new source file
- Change the directory structure
- Implement a new CLI command or API endpoint
- Change a data shape
- Make a significant architectural decision
- Complete a roadmap milestone
Update the "Last updated" date at the top of PROJECT_MAP.md with every change.
Do not let PROJECT_MAP.md drift from the actual codebase.
---
## Updating ROADMAP.md
When you complete a task:
1. Move it from "In Progress" or its milestone section to "Completed"
2. Add the completion date
3. Update the "Current Focus" section if the milestone changed
When you start a task:
1. Move it to "In Progress"
2. Note what you're working on
---
## What To Do If You Are Unsure
If you are unsure about:
- **A design decision** → check PROJECT_MAP.md first. If not covered, ask.
- **What to build next** → check ROADMAP.md current milestone.
- **How a component should behave** → check README.md for user-facing behavior.
- **Whether to add a feature** → default to no. Prefer smaller scope.
When in doubt, do less. A smaller correct implementation is better than
a larger incorrect one. This project has a clear identity — do not add
things that don't serve it.
---
## Definition of Done
A feature is done when:
- [ ] It works correctly
- [ ] `--json` output works if it's a CLI command
- [ ] Errors include helpful `hint` text
- [ ] Help text is complete (description, usage, examples, notes)
- [ ] No `.unwrap()` in non-test paths
- [ ] PROJECT_MAP.md is updated if structure changed
- [ ] ROADMAP.md is updated to reflect completion
+5
View File
@@ -0,0 +1,5 @@
# AGENTS.md
See [AGENT.md](AGENT.md) for the canonical agent instructions for this repository.
This compatibility file exists so agent tooling that expects `AGENTS.md` can
discover the same project guidance without duplicating the instructions.
Generated
+1370
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "tvctl"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0"
axum = "0.8"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
tokio = { version = "1.0", features = ["full"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1.0", features = ["serde", "v4"] }
+354
View File
@@ -0,0 +1,354 @@
# PROJECT_MAP.md
# tvctl — Living Project Map
# This file is the single source of truth for any agent working on this project.
# Read this first. Update this when you make structural changes.
# Last updated: 2026-04-14
---
## What This Project Is
`tvctl` is a Rust CLI tool and daemon (`tvctld`) that provides a unified, brand-agnostic
control layer for smart TVs. It exposes a CLI and a local HTTP API so that technical users
and tool builders can control any supported TV without caring about the underlying platform.
**One sentence:** A local-first daemon and CLI that lets technical users and tool builders
script and control smart TVs through a stable, brand-agnostic API.
---
## Project Status
**Phase:** Milestone 1 scaffolded. Foundation compiles; runtime logic not started.
**Platform v1:** Roku only (via ECP HTTP API)
**Language:** Rust
**Crate type:** Binary (single binary distribution target)
---
## Directory Structure
```
tvctl/
├── .gitignore ← Repository hygiene and runtime artifact exclusions
├── AGENTS.md ← Compatibility shim pointing to AGENT.md
├── README.md ← User-facing documentation (complete)
├── PROJECT_MAP.md ← This file. Agent context. Always read first.
├── AGENT.md ← Instructions for AI agents working on this project
├── ROADMAP.md ← Feature roadmap and milestone tracking
├── Cargo.toml ← Crate manifest and dependency definitions
├── Cargo.lock ← Locked dependency graph
├── docs/
│ ├── DESIGN.md ← Full design decisions and rationale
│ ├── API.md ← HTTP API specification (detailed)
│ └── ADAPTER.md ← Adapter trait spec and implementation guide
├── src/
│ ├── main.rs ← Binary entry point and runtime bootstrap
│ ├── cli/ ← CLI layer (clap-based scaffold)
│ │ └── mod.rs
│ ├── daemon/ ← tvctld daemon core scaffolding
│ │ ├── mod.rs
│ │ ├── registry.rs ← Device registry
│ │ ├── discovery.rs ← SSDP discovery service
│ │ ├── cache.rs ← App cache manager
│ │ └── state.rs ← State cache
│ ├── api/ ← HTTP API server scaffold (axum)
│ │ └── mod.rs
│ └── adapters/ ← Platform adapters and shared types
│ ├── mod.rs ← Adapter trait definition and core data shapes
│ └── roku/ ← Roku ECP adapter scaffold
│ └── mod.rs
└── cache/ ← Runtime cache (gitignored)
```
---
## Core Design Decisions
These are final. Do not re-open without explicit instruction.
| Decision | Choice | Reason |
|----------|--------|--------|
| Language | Rust | Developer familiarity, single binary, async ecosystem |
| CLI framework | clap | Best-in-class for Rust, shell completions, polished help |
| HTTP framework | axum | Mature, tokio-native, clean routing |
| CLI↔daemon transport | Unix socket | Secure, no port conflicts, user-only access |
| Tool builder transport | HTTP API | Stable, versioned, JSON |
| Config format | TOML | Rust ecosystem standard, human-readable |
| CLI pattern | resource-verb | Consistent, mirrors REST, scales well |
| API versioning | /v1/ from day one | Stability signal to tool builders |
| App cache strategy | Organic growth from live TV data | Accurate, small, zero maintenance |
| State cache | In-memory only, cleared on restart | Honest — never pretend to have stale state |
| Device registry | Persisted to devices.json | Survives restarts |
| Default HTTP bind | 127.0.0.1 | Secure by default |
| Key naming | kebab-case normalized | Unix convention, adapter handles translation |
| Systemd | User-level service, not system | Runs as user, not root |
---
## Architecture
```
CLI (tvctl)
└── Unix Socket
└── tvctld daemon
├── Core Router
├── Device Registry (devices.json)
├── App Cache (cache/{platform}.apps.json)
├── State Cache (in-memory)
├── Discovery Service (SSDP)
├── Adapter Registry
│ └── Roku Adapter (ECP/HTTP)
└── HTTP API Server (:7272)
└── /v1/... (tool builders)
```
---
## Adapter Trait
The central abstraction. All platform implementations must satisfy this trait.
Defined in `src/adapters/mod.rs`.
```rust
pub trait TvAdapter: Send + Sync {
async fn discover(&self) -> Result<Vec<DeviceInfo>>;
async fn state(&self, device: &Device) -> Result<DeviceState>;
async fn launch(&self, device: &Device, app: &str) -> Result<()>;
async fn stop_app(&self, device: &Device) -> Result<()>;
async fn key(&self, device: &Device, key: TvKey) -> Result<()>;
async fn sequence(&self, device: &Device, keys: Vec<TvKey>) -> Result<()>;
async fn list_apps(&self, device: &Device) -> Result<Vec<AppInfo>>;
async fn dev_install(&self, device: &Device, zip: &[u8]) -> Result<()> {
Err(TvError::NotSupported("dev_install"))
}
async fn dev_reload(&self, device: &Device) -> Result<()> {
Err(TvError::NotSupported("dev_reload"))
}
async fn dev_logs(&self, device: &Device) -> Result<Vec<String>> {
Err(TvError::NotSupported("dev_logs"))
}
}
```
Dev methods have default implementations that return `NotSupported`.
Platforms that support them override. Nothing above the adapter layer
needs to know which platforms support which features.
---
## Data Shapes
### Device
```rust
pub struct Device {
pub id: Uuid,
pub name: String, // user-assigned friendly name
pub original_name: String, // name as reported by device at discovery
pub platform: String, // "roku" | "googletv" | "firetv"
pub address: IpAddr,
pub port: u16,
pub is_default: bool,
pub discovered_at: DateTime<Utc>,
pub last_seen: DateTime<Utc>,
}
```
### DeviceState
```rust
pub struct DeviceState {
pub device_id: Uuid,
pub power: PowerState, // On | Off | Unknown
pub active_app: Option<AppInfo>,
pub volume: Option<VolumeInfo>,
pub timestamp: DateTime<Utc>,
}
```
### AppInfo
```rust
pub struct AppInfo {
pub id: String, // tvctl normalized id
pub name: String,
pub version: Option<String>,
pub platform_id: String, // raw platform value
}
```
### TvKey (normalized key enum)
```rust
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), // for text input
}
```
---
## CLI Command Tree
```
tvctl
├── (bare) full help output
├── daemon
│ ├── start
│ ├── stop
│ ├── restart
│ ├── status
│ ├── install generate + enable systemd user service
│ └── uninstall
├── device
│ ├── list
│ ├── discover
│ ├── add --address <ip> --platform <platform>
│ ├── select <name>
│ ├── info <name>
│ └── remove <name>
├── app
│ ├── list
│ ├── launch <name|id>
│ ├── stop
│ └── refresh
├── remote
│ ├── key <key>
│ └── sequence <key> [key...]
├── state
├── dev
│ ├── install <zip>
│ ├── reload
│ └── logs
└── config
├── list
├── get <key>
├── set <key> <value>
├── reset
└── reload
Global flags: --device <name|id> --json --help --version
```
---
## HTTP API Surface
Base: `http://127.0.0.1:7272/v1`
```
GET /v1/devices
POST /v1/devices/discover
GET /v1/devices/{id}
DELETE /v1/devices/{id}
GET /v1/devices/{id}/state
GET /v1/devices/{id}/apps
POST /v1/devices/{id}/apps/launch body: {"app": "netflix"}
POST /v1/devices/{id}/apps/stop
POST /v1/devices/{id}/apps/refresh
POST /v1/devices/{id}/remote/key body: {"key": "home"}
POST /v1/devices/{id}/remote/sequence body: {"keys": ["home", "down"]}
POST /v1/devices/{id}/dev/install body: multipart zip
POST /v1/devices/{id}/dev/reload
GET /v1/devices/{id}/dev/logs
GET /v1/daemon/status
GET /v1/config
PATCH /v1/config
POST /v1/config/reload
```
Response envelope (always):
```json
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "hint": "..." } }
```
---
## Storage Layout
```
~/.config/tvctl/config.toml ← user config (TOML)
~/.local/share/tvctl/devices.json ← device registry (daemon managed)
~/.local/share/tvctl/cache/
roku.apps.json ← organic app cache per platform
googletv.apps.json
firetv.apps.json
/run/user/{uid}/tvctl.sock ← unix socket (runtime)
```
---
## Config Schema
```toml
[daemon]
socket = "/run/user/1000/tvctl.sock"
http_enabled = true
http_port = 7272
http_host = "127.0.0.1"
log_level = "info"
[discovery]
auto_discover = true
interval_secs = 300
timeout_secs = 5
[devices]
default = ""
[dev]
enabled = true
```
---
## Platform Support
| Platform | Status | Protocol |
|----------|--------|----------|
| Roku | v1 target | ECP (HTTP on port 8060) |
| Google TV | post-MVP | ADB |
| Fire TV | post-MVP | ADB |
| Samsung | deprioritized | Tizen (unstable app IDs) |
---
## Known Risks
| Risk | Severity | Mitigation |
|------|----------|------------|
| Roku ECP changes without notice | High/Low-likelihood | Adapter isolation |
| SSDP blocked on some networks | Medium | Manual device add fallback |
| App name resolution ambiguity | Low | Raw --id flag fallback |
| State staleness | Low | Honest timestamps on all state |
| Unix socket permissions | Medium | 0600, user-level systemd service |
| Platform creep | Medium | Roku-first discipline in v1 |
---
## What Has NOT Been Started
- Roku ECP transport and device discovery logic
- Daemon runtime, socket transport, and persistence logic
- HTTP route handlers and request validation
- Real CLI command handling beyond skeleton parsing
- Any tests
- CI/CD configuration
- Release/packaging
---
## Glossary
| Term | Meaning |
|------|---------|
| ECP | External Control Protocol — Roku's HTTP control API on port 8060 |
| SSDP | Simple Service Discovery Protocol — used for device discovery on LAN |
| Adapter | A platform-specific implementation of the TvAdapter trait |
| Registry | The persisted list of known devices (devices.json) |
| Platform cache | Per-platform app list cache (grows from live TV data) |
| Device cache | Per-device installed app list (subset of platform cache) |
+469
View File
@@ -0,0 +1,469 @@
# tvctl
> A local-first daemon and CLI that provides a unified, brand-agnostic control layer for smart TVs —
> giving technical users and tool builders a stable, scriptable API without caring what's connected.
---
## What is tvctl?
`tvctl` is a self-hosted control plane for smart TVs. It runs as a background daemon (`tvctld`) on your
Linux machine and exposes two interfaces:
- A **CLI** (`tvctl`) for direct control, scripting, and exploration
- A **local HTTP API** (`/v1/...`) for tool builders and automation
The goal is simple: make your TV fully scriptable and explorable from the command line, without cloud
dependencies, without proprietary apps, and without caring what brand of TV you own.
If you've ever wanted to `curl` your TV, write a script that launches an app, automate input switching,
or build your own dashboard — `tvctl` is the foundation that makes that possible.
---
## Who is this for?
**Homelab operators** who want their TV under the same level of control as the rest of their infrastructure.
**Tool builders** who want to build TV-adjacent tooling (stream deck plugins, Home Assistant integrations,
automation scripts) without reinventing device discovery and platform APIs from scratch.
**Curious technical users** who want to explore what their TV can actually do.
`tvctl` is not for average TV users. If you're comfortable with a terminal and you've ever felt limited
by your TV's remote, you're the target audience.
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────┐
│ tvctld │
│ │
│ ┌─────────────┐ ┌──────────────────────────┐ │
│ │ Unix Socket │ │ HTTP API (:7272) │ │
│ │ (CLI only) │ │ /v1/... (tool builders) │ │
│ └──────┬──────┘ └────────────┬─────────────┘ │
│ │ │ │
│ └──────────┬─────────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ Core Router │ │
│ └─────────┬──────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ┌──▼───┐ ┌─────▼────┐ ┌────▼────┐ │
│ │Device│ │ App │ │Discovery│ │
│ │Registry │ Cache │ │Service │ │
│ └──────┘ └──────────┘ └─────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ Adapter Registry │ │
│ └─────────┬──────────┘ │
│ │ │
│ ┌──────────┴──────────┐ │
│ │ │ │
│ ┌────▼────┐ ┌─────▼──────┐ │
│ │ Roku │ │ Google TV │ ← future │
│ │ Adapter │ │ Adapter │ │
│ │ (ECP) │ │ (ADB) │ │
│ └─────────┘ └────────────┘ │
└─────────────────────────────────────────────────────┘
```
### Components
**`tvctld` — The Daemon**
The heart of the system. Runs as a persistent background service (systemd user unit). Owns device
discovery, the device registry, app cache, and state cache. All CLI commands and API requests go
through the daemon.
**Unix Socket**
The CLI communicates with the daemon exclusively via a Unix socket (`~/.run/user/1000/tvctl.sock`).
Fast, secure, no port conflicts. The socket is only accessible to the running user.
**HTTP API**
The daemon optionally exposes a local HTTP API on `127.0.0.1:7272`. This is the interface for tool
builders — Home Assistant integrations, stream deck plugins, scripts, dashboards. Loopback-only by
default. All responses are JSON.
**Adapter Layer**
Each TV platform is implemented as an adapter behind a common Rust trait. The adapter handles all
platform-specific protocol details. Above the adapter layer, nothing knows or cares whether the TV
is a Roku, Google TV, or anything else. Adding a new platform means implementing the adapter trait —
nothing else changes.
**Device Registry**
Persisted to `~/.local/share/tvctl/devices.json`. Survives daemon restarts. Devices are discovered
once and remembered forever (or until removed). Each device has a UUID, a friendly name, and a
platform tag that maps it to an adapter.
**App Cache**
Per-platform, persisted to `~/.local/share/tvctl/cache/{platform}.apps.json`. Grows organically
from live TV data — no pre-populated database. The first time you launch an app by name, tvctld
fetches the full app list from the TV, caches it, and never fetches it again unless you ask it to.
App IDs are stable on all supported platforms.
**State Cache**
In-memory only. Last known state per device. Includes power status, active app, and volume. Always
includes a timestamp so callers know how fresh the data is. Cleared on daemon restart.
---
## CLI Reference
`tvctl` uses a consistent **resource-verb** pattern throughout:
```
tvctl <resource> <verb> [args] [flags]
```
Global flags available on every command:
```
--device <name|id> Target a specific device (overrides default)
--json Output as JSON for scripting
--help Show help for any command
--version Show tvctl version
```
### daemon
Manage the tvctld background service.
```
tvctl daemon start Start the daemon
tvctl daemon stop Stop the daemon
tvctl daemon restart Restart the daemon
tvctl daemon status Show daemon status
tvctl daemon install Generate and enable systemd user service
tvctl daemon uninstall Remove systemd user service
```
### device
Discover and manage connected TVs.
```
tvctl device list List all known devices
tvctl device discover Scan network for TVs (SSDP)
tvctl device add --address <ip> Manually add a device
tvctl device select <name|id> Set default device
tvctl device info <name|id> Show device details
tvctl device remove <name|id> Remove from registry
```
### app
Manage and launch TV applications.
```
tvctl app list List installed apps on current device
tvctl app launch <name|id> Launch an app by name or platform ID
tvctl app stop Stop the current app
tvctl app refresh Refresh app cache from TV
```
### remote
Send input to the TV.
```
tvctl remote key <key> Send a single keypress
tvctl remote sequence <key> [key...] Send a sequence of keypresses
```
**Available keys:**
| Category | Keys |
|------------|---------------------------------------------------|
| Navigation | `home` `back` `up` `down` `left` `right` `select` |
| Playback | `play` `pause` `play-pause` `stop` `rewind` `fast-forward` `replay` `skip` |
| Channel | `channel-up` `channel-down` |
| Volume | `volume-up` `volume-down` `mute` |
| Power | `power` `power-on` `power-off` |
| Input | `input-hdmi1` `input-hdmi2` `input-hdmi3` `input-hdmi4` `input-av` `input-tuner` |
| Other | `search` `info` `options` |
### state
Query current device state.
```
tvctl state Show state of default device
tvctl state --device <name> Show state of a specific device
```
### dev
Developer tools. Requires `dev.enabled = true` in config.
```
tvctl dev install <app.zip> Sideload a zip to the TV
tvctl dev reload Reload the currently sideloaded app
tvctl dev logs Fetch developer logs
```
### config
View and manage tvctl configuration.
```
tvctl config list Show all config values
tvctl config get <key> Get a specific value
tvctl config set <key> <value> Set a config value
tvctl config reset Reset to defaults
tvctl config reload Hot-reload config into running daemon
```
---
## HTTP API Reference
The API mirrors the CLI exactly. If you know the CLI, you know the API.
Base URL: `http://127.0.0.1:7272/v1`
All requests and responses are JSON. All responses use a consistent envelope:
```json
{ "ok": true, "data": { ... } }
```
Errors:
```json
{
"ok": false,
"error": {
"code": "device_not_found",
"message": "No device with id 'bedroom' exists.",
"hint": "Run tvctl device list to see known devices."
}
}
```
`code` is stable and machine-readable. `hint` is human-readable and may change between versions.
Tool builders should match on `code`, never on `hint` or `message`.
### Endpoints
```
GET /v1/devices List all devices
POST /v1/devices/discover Trigger discovery
GET /v1/devices/{id} Device info
DELETE /v1/devices/{id} Remove device
GET /v1/devices/{id}/state Device state
GET /v1/devices/{id}/apps List installed apps
POST /v1/devices/{id}/apps/launch Launch app
POST /v1/devices/{id}/apps/stop Stop current app
POST /v1/devices/{id}/apps/refresh Refresh app cache
POST /v1/devices/{id}/remote/key Send keypress
POST /v1/devices/{id}/remote/sequence Send key sequence
POST /v1/devices/{id}/dev/install Sideload zip
POST /v1/devices/{id}/dev/reload Reload sideloaded app
GET /v1/devices/{id}/dev/logs Fetch logs
GET /v1/daemon/status Daemon health
GET /v1/config Get full config
PATCH /v1/config Update config values
POST /v1/config/reload Hot reload config
```
Devices can be addressed by UUID or friendly name:
```
GET /v1/devices/a3f2c1d4-.../state
GET /v1/devices/living-room/state ← same result
```
### Example Requests
```bash
# List devices
curl http://127.0.0.1:7272/v1/devices
# Launch Netflix on living room TV
curl -X POST http://127.0.0.1:7272/v1/devices/living-room/apps/launch \
-H "Content-Type: application/json" \
-d '{"app": "netflix"}'
# Send keypress
curl -X POST http://127.0.0.1:7272/v1/devices/living-room/remote/key \
-H "Content-Type: application/json" \
-d '{"key": "home"}'
# Get state
curl http://127.0.0.1:7272/v1/devices/living-room/state
```
---
## Data Shapes
### Device
```json
{
"id": "a3f2c1d4-9b3e-4f1a-8c2d-1e5f7a9b3c4d",
"name": "Living Room",
"original_name": "Living Room Roku",
"platform": "roku",
"address": "192.168.1.42",
"port": 8060,
"is_default": true,
"discovered_at": "2026-04-14T10:22:00Z",
"last_seen": "2026-04-14T11:05:00Z"
}
```
### State
```json
{
"device_id": "a3f2c1d4-...",
"power": "on",
"active_app": {
"id": "12",
"name": "Netflix"
},
"volume": {
"level": 42,
"muted": false
},
"timestamp": "2026-04-14T11:05:00Z"
}
```
### App
```json
{
"id": "12",
"name": "Netflix",
"version": "4.1.218",
"platform_id": "12"
}
```
---
## Configuration
Located at `~/.config/tvctl/config.toml`
```toml
[daemon]
socket = "/run/user/1000/tvctl.sock"
http_enabled = true
http_port = 7272
http_host = "127.0.0.1"
log_level = "info"
[discovery]
auto_discover = true
interval_secs = 300
timeout_secs = 5
[devices]
default = "living-room"
[dev]
enabled = true
```
### Config Keys
| Key | Default | Description |
|-----|---------|-------------|
| `daemon.socket` | `/run/user/{uid}/tvctl.sock` | Unix socket path |
| `daemon.http_enabled` | `true` | Expose HTTP API |
| `daemon.http_port` | `7272` | HTTP API port |
| `daemon.http_host` | `127.0.0.1` | Bind address (loopback by default) |
| `daemon.log_level` | `info` | debug / info / warn / error |
| `discovery.auto_discover` | `true` | Discover devices on daemon start |
| `discovery.interval_secs` | `300` | Re-scan interval (0 = disabled) |
| `discovery.timeout_secs` | `5` | Per-device timeout |
| `devices.default` | `""` | Default device name or UUID |
| `dev.enabled` | `true` | Enable dev tooling commands |
---
## Storage Layout
```
~/.config/tvctl/
└── config.toml
~/.local/share/tvctl/
├── devices.json ← device registry (daemon managed)
└── cache/
├── roku.apps.json ← organic app cache, grows from live data
├── googletv.apps.json
└── firetv.apps.json
```
---
## Platform Support
| Platform | Status | Protocol | Notes |
|----------|--------|----------|-------|
| Roku | v1 | ECP (HTTP) | Full support |
| Google TV | planned | ADB | Post-MVP |
| Fire TV | planned | ADB | Post-MVP |
| Samsung | investigated | Tizen | Deprioritized — unstable app IDs |
---
## Getting Started
```bash
# Install (once binary releases exist)
# cargo install tvctl
# Start the daemon
tvctl daemon start
# Or install as a systemd user service
tvctl daemon install
# Discover TVs on your network
tvctl device discover
# Set your default device
tvctl device select "Living Room"
# Launch an app
tvctl app launch netflix
# Send a keypress
tvctl remote key home
# Query state
tvctl state
# Scripting example
tvctl state --json | jq '.data.active_app.name'
```
---
## For Tool Builders
`tvctl` is designed to be a stable foundation for other tools. The HTTP API is versioned from day one
(`/v1/...`) and the data shapes are intentional contracts.
If you're building a Home Assistant integration, a stream deck plugin, or any other TV-adjacent tool:
- Use the HTTP API, not the CLI, for programmatic access
- Address devices by UUID for stability, not friendly name
- Match on `error.code` for error handling, never on `error.message`
- The API is loopback-only by default — document this for your users
---
## License
MIT
+172
View File
@@ -0,0 +1,172 @@
# ROADMAP.md
# tvctl — Feature Roadmap and Milestone Tracker
# Agents: update this file as work is completed. See AGENT.md for instructions.
# Last updated: 2026-04-14
---
## Current Focus
**Milestone 2 — Roku Adapter**
Foundation scaffold is complete. Begin platform implementation work.
---
## In Progress
_Nothing in progress yet._
---
## Milestone 1 — Project Foundation
_Goal: A compiling Rust project with correct structure and no logic yet._
_Completed 2026-04-14. See Completed below._
---
## Milestone 2 — Roku Adapter
_Goal: Can communicate with a real Roku TV over ECP._
- [ ] Implement Roku ECP adapter in `src/adapters/roku/`
- [ ] `discover()` — SSDP scan returning Roku devices
- [ ] `list_apps()` — fetch installed channel list via ECP
- [ ] `launch()` — launch app by ECP channel ID
- [ ] `stop_app()` — exit current app
- [ ] `key()` — send ECP keypress
- [ ] `sequence()` — send multiple keypresses
- [ ] `state()` — query power state and active app
- [ ] `dev_install()` — zip upload via ECP dev mode
- [ ] `dev_reload()` — reload sideloaded app
- [ ] `dev_logs()` — fetch dev logs
- [ ] Key translation table (TvKey → Roku ECP key string)
- [ ] Manual integration test against real Roku device
---
## Milestone 3 — Daemon Core
_Goal: tvctld runs, manages devices, and handles the Unix socket._
- [ ] Daemon entry point and lifecycle (`src/daemon/mod.rs`)
- [ ] Unix socket listener
- [ ] Device registry (`src/daemon/registry.rs`)
- Load from `devices.json` on start
- Persist on change
- CRUD operations
- [ ] Discovery service (`src/daemon/discovery.rs`)
- SSDP scan
- Auto-discover on startup (if configured)
- Interval-based re-scan
- Manual add by IP
- [ ] App cache manager (`src/daemon/cache.rs`)
- Per-platform JSON files
- Organic growth strategy
- `app refresh` invalidation
- [ ] State cache (`src/daemon/state.rs`)
- In-memory only
- Per-device last-known state
- Timestamp on every entry
- [ ] Adapter registry (map platform string → adapter instance)
- [ ] Config loading from TOML
---
## Milestone 4 — CLI
_Goal: All tvctl commands work against a running daemon._
- [ ] CLI entry point and dispatch (`src/cli/mod.rs`)
- [ ] Unix socket client (send commands, receive responses)
- [ ] `tvctl daemon` commands
- `start` `stop` `restart` `status`
- `install` (generate systemd user unit)
- `uninstall`
- [ ] `tvctl device` commands
- `list` `discover` `add` `select` `info` `remove`
- [ ] `tvctl app` commands
- `list` `launch` `stop` `refresh`
- [ ] `tvctl remote` commands
- `key` `sequence`
- [ ] `tvctl state`
- [ ] `tvctl dev` commands
- `install` `reload` `logs`
- [ ] `tvctl config` commands
- `list` `get` `set` `reset` `reload`
- [ ] Global flags: `--device` `--json` `--help` `--version`
- [ ] Full help text on every command (see AGENT.md definition of done)
- [ ] Full help output on bare `tvctl`
- [ ] Friendly error messages with hints on every failure path
- [ ] `--json` output verified on every command
---
## Milestone 5 — HTTP API
_Goal: Full /v1/ API running on 127.0.0.1:7272._
- [ ] axum server setup in `src/api/mod.rs`
- [ ] All routes implemented (see PROJECT_MAP.md API surface)
- [ ] Standard response envelope on all routes
- [ ] Error responses with `code` + `message` + `hint`
- [ ] Device addressable by UUID or friendly name on all routes
- [ ] `PATCH /v1/config` with partial update support
- [ ] `POST /v1/config/reload` triggers live config reload in daemon
- [ ] Integration test: curl all endpoints against running daemon
---
## Milestone 6 — Polish and Release Prep
_Goal: Ready for real use._
- [ ] Shell completions (bash, zsh, fish) via clap
- [ ] `tvctl daemon install` generates correct systemd unit file
- [ ] First-run experience: helpful output when no devices discovered yet
- [ ] Daemon startup message with socket path and HTTP port
- [ ] Log output via `tracing` (respects `log_level` config)
- [ ] README accuracy pass (verify all examples work)
- [ ] `cargo clippy` clean
- [ ] `cargo test` passing
- [ ] Cross-compile test (x86_64 + aarch64)
- [ ] GitHub Actions CI (build + clippy + test)
- [ ] First binary release
---
## Post-MVP (Do Not Implement in v1)
These are captured here so they are not forgotten, but they are explicitly
out of scope until Milestone 6 is complete and stable.
- [ ] Google TV / Android TV adapter (ADB)
- [ ] Fire TV adapter (ADB variant)
- [ ] WebSocket live state updates
- [ ] Event watching / triggers
- [ ] Device groups (send command to multiple TVs)
- [ ] Automation rules engine
- [ ] Home Assistant integration
- [ ] Web UI (consumes HTTP API, no business logic)
- [ ] `tvctl dev logs` streaming (currently returns last N lines only)
- [ ] macOS support (launchd instead of systemd)
---
## Completed
- [x] 2026-04-14 — Initialize Cargo workspace (`cargo init`)
- [x] 2026-04-14 — Add baseline dependencies to `Cargo.toml`
- [x] 2026-04-14 — Create module skeleton and placeholder docs layout
- [x] 2026-04-14 — Define the adapter contract and core shared data types
- [x] 2026-04-14 — Compile the project cleanly with `cargo build`
---
## Decision Log
Significant decisions made during development should be logged here
so future agents understand why things are the way they are.
| Date | Decision | Reason |
|------|----------|--------|
| 2026-04-14 | Full design completed before any code written | Intentional — design-first approach |
| 2026-04-14 | Samsung deprioritized | Unstable app IDs per region/model make universal support unreliable |
| 2026-04-14 | No pre-populated app database | Organic cache from live TV data is more accurate and zero-maintenance |
| 2026-04-14 | Unix socket for CLI, HTTP for tool builders | Clean security boundary, loopback-only by default |
| 2026-04-14 | User-level systemd service | No root required, correct ownership model |
+33
View File
@@ -0,0 +1,33 @@
# ADAPTER.md
# tvctl Adapter Contract
The adapter layer isolates platform-specific TV control protocols from the
rest of `tvctl`. Everything above this layer speaks the normalized tvctl
data model; everything below it speaks the platform's native protocol.
## Required Trait
The canonical trait lives in `src/adapters/mod.rs`:
```rust
pub trait TvAdapter: Send + Sync {
async fn discover(&self) -> Result<Vec<DeviceInfo>>;
async fn state(&self, device: &Device) -> Result<DeviceState>;
async fn launch(&self, device: &Device, app: &str) -> Result<()>;
async fn stop_app(&self, device: &Device) -> Result<()>;
async fn key(&self, device: &Device, key: TvKey) -> Result<()>;
async fn sequence(&self, device: &Device, keys: Vec<TvKey>) -> Result<()>;
async fn list_apps(&self, device: &Device) -> Result<Vec<AppInfo>>;
async fn dev_install(&self, device: &Device, zip: &[u8]) -> Result<()>;
async fn dev_reload(&self, device: &Device) -> Result<()>;
async fn dev_logs(&self, device: &Device) -> Result<Vec<String>>;
}
```
## Rules
- The adapter surface is the boundary between normalized and platform data.
- Adapters translate normalized `TvKey` values into platform-specific input.
- Platform support belongs in a dedicated subdirectory under `src/adapters/`.
- Dev-mode methods may return `NotSupported` when a platform lacks that feature.
- The CLI, daemon, and HTTP API must not contain platform-specific logic.
+60
View File
@@ -0,0 +1,60 @@
# API.md
# tvctl HTTP API Specification
`tvctl` exposes a loopback-only HTTP API for tool builders at `/v1/...`.
This file captures the v1 surface promised by the design bundle so the
repository has a dedicated API spec from day one.
## Base URL
`http://127.0.0.1:7272/v1`
## Response Envelope
Successful responses:
```json
{ "ok": true, "data": { "...": "..." } }
```
Error responses:
```json
{
"ok": false,
"error": {
"code": "device_not_found",
"message": "No device with id 'living-room' exists.",
"hint": "Run tvctl device list to see known devices."
}
}
```
## Planned Routes
- `GET /devices`
- `POST /devices/discover`
- `GET /devices/{id}`
- `DELETE /devices/{id}`
- `GET /devices/{id}/state`
- `GET /devices/{id}/apps`
- `POST /devices/{id}/apps/launch`
- `POST /devices/{id}/apps/stop`
- `POST /devices/{id}/apps/refresh`
- `POST /devices/{id}/remote/key`
- `POST /devices/{id}/remote/sequence`
- `POST /devices/{id}/dev/install`
- `POST /devices/{id}/dev/reload`
- `GET /devices/{id}/dev/logs`
- `GET /daemon/status`
- `GET /config`
- `PATCH /config`
- `POST /config/reload`
## API Rules
- All routes return the standard JSON envelope.
- `error.code` values are stable machine contracts.
- `error.hint` is optional but recommended for user-actionable failures.
- Devices may be addressed by UUID or friendly name.
- Platform-specific field names must not leak past the adapter layer.
+181
View File
@@ -0,0 +1,181 @@
# DESIGN.md
# tvctl — Design Decisions and Rationale
# This document explains the why behind decisions in PROJECT_MAP.md.
# Read this if you want to understand the reasoning, not just the choices.
---
## Vision
tvctl exists because the Roku ECP API (and similar TV control APIs) are fully capable
but completely inaccessible to technical users without writing custom code. There is no
good CLI tool, no stable local API, and no abstraction layer that works across TV brands.
The goal is to be that abstraction layer — the foundation that other tools are built on,
and a useful tool in its own right.
---
## Why a Daemon?
A stateless CLI that talks directly to TVs on every invocation would be simpler to build
but has fundamental limitations:
- Device discovery (SSDP) takes time — you don't want it on every command
- The app cache needs to live somewhere persistent and shared
- An HTTP API for tool builders needs a persistent process
- State caching requires something running continuously
The daemon owns all of this. The CLI is a thin client. This is the correct architecture
for a tool with these requirements.
---
## Why Unix Socket for CLI, HTTP for API?
Two different audiences, two different needs:
**CLI users** are local, trusted, and interactive. A Unix socket is:
- Faster than HTTP for local IPC
- Inherently secure (filesystem permissions, user-only access)
- No port to conflict with other software
- The right tool for local process communication
**Tool builders** need HTTP because:
- Every language has an HTTP client
- REST is a universal interface
- It works from scripts, Home Assistant, stream decks, anything
- Versioning (`/v1/`) is natural in HTTP
Keeping these separate means the HTTP API port is optional — you can disable it entirely
and still use the full CLI. Security-conscious users appreciate this.
---
## Why Resource-Verb CLI Pattern?
```
tvctl device list ← resource-verb
tvctl list-devices ← verb-resource (rejected)
```
Resource-verb was chosen because:
1. It mirrors REST naturally — `GET /devices``tvctl device list`
2. It scales — as commands grow, the namespace stays organized
3. Tab completion is more useful — type `tvctl device <tab>` to see all device commands
4. It matches tools users already know (`git remote add`, `kubectl pod get`)
The 1:1 mapping between CLI and API is intentional and valuable. Learn one, know both.
---
## Why Organic App Cache?
The temptation is to ship a pre-populated database of app IDs. This was explicitly rejected:
- It requires ongoing maintenance as apps are added/removed
- It would contain apps the user doesn't have installed (bloat, confusion)
- App IDs are stable — once discovered, they never need to be re-fetched
- The first-fetch delay is tiny and happens once per app, ever
The organic approach means the cache is always perfectly accurate for this user's TV.
It grows automatically through normal use. It requires zero maintenance.
---
## Why Loopback-Only HTTP by Default?
The HTTP API exposes control over your TV. Binding to `0.0.0.0` by default would mean
anyone on your network could control your TV without authentication.
Loopback-only (`127.0.0.1`) is the secure default. Users who want network exposure can
change `http_host` in config — but they make that choice consciously.
There is no authentication system in v1. The security model is network isolation.
This is appropriate for a local tool used by one person.
---
## Why User-Level Systemd Service?
`tvctl daemon install` installs a systemd **user** service, not a system service. This means:
- No `sudo` required at any point
- The daemon runs as the installing user
- The Unix socket is owned by that user
- Uninstalling is clean and doesn't require root
System-level services are for system daemons. tvctld is a user tool.
---
## Why TOML for Config?
- It's the de facto standard in the Rust ecosystem
- Human-readable and editable without special knowledge
- Better than YAML (no indentation pitfalls, no Norway problem)
- Better than JSON for config (supports comments, more readable)
- `serde` + `toml` crate support is first-class in Rust
---
## Why kebab-case for Key Names?
```
volume-up ← chosen
VolumeUp ← Roku's internal name (rejected at surface)
volume_up ← snake_case (rejected)
```
- kebab-case is the Unix CLI convention for multi-word values
- It's consistent with how flags are named (`--json`, `--device`)
- The adapter layer handles translation to platform-specific names
- Users never need to know Roku uses `VolumeUp` internally
---
## The Adapter Abstraction
This is the most important architectural decision in the project.
The adapter trait is a wall between the platform world and the application world.
Everything above the adapter layer speaks tvctld's language. Everything below speaks
the platform's language.
Consequences:
- Adding Google TV support means writing one new file (`src/adapters/googletv/`)
- The CLI, API, registry, and cache don't change at all
- Platform bugs are isolated to their adapter
- Testing adapters is straightforward — mock the trait
The dev methods (`dev_install`, `dev_reload`, `dev_logs`) have default implementations
that return `NotSupported`. This means the trait can be implemented by platforms that
don't support developer mode without those platforms needing to handle it explicitly.
---
## State Cache Design
State is cached in memory only and cleared on daemon restart. This is intentional:
- TVs don't push state — you have to poll
- Cached state can be stale by definition
- Pretending to have live state you don't have is dishonest and confusing
- Every state response includes a `timestamp` so callers know how fresh it is
In a future version, WebSocket streaming or polling could make state more live.
For v1, honest polling with clear timestamps is the right approach.
---
## App Resolution Priority
When resolving an app name to a platform ID:
1. Per-device installed app list (in memory, freshest)
2. Platform cache (on disk, still accurate)
3. Live fetch from TV (fallback, always works)
This order minimizes network calls while always being able to resolve.
The user can also bypass resolution entirely with a raw `--id` flag.
+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
}