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,4 @@
|
|||||||
|
/target/
|
||||||
|
/cache/
|
||||||
|
/tvctl-agent-base.zip
|
||||||
|
.DS_Store
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+18
@@ -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
@@ -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) |
|
||||||
@@ -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
@@ -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 |
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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