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
+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.