Expose the daemon request surface over /v1 with Axum, reuse shared key parsing between CLI and HTTP, and add an isolated end-to-end HTTP test that boots a real daemon process with temp XDG paths.
11 KiB
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 5 in progress. Daemon core and CLI are complete for v1 Roku control, and the /v1 HTTP API server is now wired to the same daemon request surface. Remaining work is automated API validation and any follow-up transport cleanup.
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/
│ ├── lib.rs ← Library surface for shared modules and integration tests
│ ├── main.rs ← Binary entry point and runtime bootstrap
│ ├── cli/ ← CLI layer (clap-based scaffold)
│ │ └── mod.rs
│ ├── daemon/ ← tvctld daemon core scaffolding
│ │ ├── mod.rs
│ │ ├── config.rs ← Config loading and runtime path helpers
│ │ ├── ipc.rs ← Unix socket request/response protocol
│ │ ├── 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 implementation
│ └── mod.rs
├── tests/
│ └── roku_live.rs ← Live Roku integration tests gated by env vars
└── 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.
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
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
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
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)
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
│ └── send <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):
{ "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
[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
roku_username = ""
roku_password = ""
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
- HTTP route handlers and request validation
- 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) |