0095462216
Collapse remote input to `tvctl remote key` so single-key and multi-key usage share one public command while keeping the paced daemon path underneath.
473 lines
15 KiB
Markdown
473 lines
15 KiB
Markdown
# 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> [key...] Send one or more 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
|
|
roku_username = ""
|
|
roku_password = ""
|
|
```
|
|
|
|
### 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 |
|
|
| `dev.roku_username` | `""` | Optional Roku developer username override |
|
|
| `dev.roku_password` | `""` | Optional Roku developer password override |
|
|
|
|
---
|
|
|
|
## 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
|