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.
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]
Known Issues
- Some Roku secret-menu sequences sent through
tvctl remote senddo not reliably open the expected menu, even when the same sequence works on the physical Roku remote. - Current Roku input delivery uses ECP
keypress/...requests only. Roku's official ECP docs also documentkeydown/...andkeyup/..., which likely need investigation for higher-fidelity secret-menu automation. - Normal navigation and app control work as expected, but Roku secret-menu automation should currently be treated as experimental.
- Tracking issue: #1 Investigate Roku secret-menu sequence reliability over ECP
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 daemon. When the user service is installed, these commands manage the systemd user service instead of an ad hoc background process.
tvctl daemon start Start the daemon or installed user service
tvctl daemon stop Stop the daemon or installed user service
tvctl daemon restart Restart the daemon or installed user service
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
tvctl app refresh --clear clears the persisted cache for the current platform
before reloading it from the selected device. Use it when cached app names or IDs
look stale, or when removed apps are still showing up in the cache.
remote
Send input to the TV.
tvctl remote send <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:
{ "ok": true, "data": { ... } }
Errors:
{
"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
# 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
{
"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
{
"device_id": "a3f2c1d4-...",
"power": "on",
"active_app": {
"id": "12",
"name": "Netflix"
},
"volume": {
"level": 42,
"muted": false
},
"timestamp": "2026-04-14T11:05:00Z"
}
App
{
"id": "12",
"name": "Netflix",
"version": "4.1.218",
"platform_id": "12"
}
Configuration
Located at ~/.config/tvctl/config.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
# Install (once binary releases exist)
# cargo install tvctl
# Start the daemon ad hoc
tvctl daemon start
# Or install as a systemd user service
# After install, daemon start/stop/restart manage the 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 send 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.codefor error handling, never onerror.message - The API is loopback-only by default — document this for your users
License
MIT