Skip to content

About

Web panel and MCP server for Android TV (Shield, Fire TV) and LG webOS TVs

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

tv-control

A web panel and MCP server for a small fleet of TVs: NVIDIA Shields and Fire TVs over ADB, LG webOS TVs over aiowebostv and Wake-on-LAN. One container serves both, driven by one devices.yml.

Features

  • Remote control per device: D-pad, media, volume, and home keys; app launch with quick-launch buttons; on-screen toasts.
  • Fast keys on Shields: after a one-time pairing, keys go over the Android TV Remote v2 protocol (androidtvremote2) in milliseconds instead of about 1 s per adb shell input. Unpaired Shields and any Remote v2 failure fall back to ADB.
  • Live status over Server-Sent Events (/events, one update per device every 10 s), so cards refresh without polling.
  • Installable PWA: web manifest and icons, content-hashed static assets served immutable.
  • Power, wake (WoL through an SSH relay on the TV's LAN), input switching, and sleep timers. LG webOS inputs switch through aiowebostv; Fire TV inputs switch through a passthrough intent (needs input_ids, does not wake the TV).
  • Shield "find my remote" (locator), device status, screenshots, and an admin screen preview.
  • Health checks, including paired TVs: a Shield with paired_tv reports standby when its TV is off, and a fault only when the TV is unreachable while the Shield is awake and playing.
  • Site LAN scan for new ADB or webOS devices, with an admin approve step.
  • MCP server (streamable HTTP) with the same verbs, for an AI agent or a script. python3 /app/app.py call <verb> key=value calls it from inside the container.
  • An app denylist: denied_apps entries are never launched, by the panel or by MCP.

Security model

The panel is meant to sit behind a reverse proxy with forward auth (built and tested with Authentik proxy providers).

  • Every panel route except /healthz needs the header X-TV-Proxy-Key equal to TV_PROXY_KEY. Only the reverse proxy sets it, so a client that reaches the port directly cannot forge the user headers. Without TV_PROXY_KEY the panel answers 503.
  • The user comes from X-authentik-username, and groups from X-authentik-groups.
  • Group tv-admin sees and drives every device. Group tv-site-<site> sees only that site's devices and can use key, launch, sleep timer, power, wake, input, and toast.
  • Reboot, pair (webOS pairing and Shield fast-key pairing), baseline, lockdown, scan, approve, screenshot, and screen preview are admin-only. A denial returns 403 with error, cause, and next_step.
  • POST routes need X-TV-Panel: 1, which a cross-site form cannot send. Real baseline and lockdown runs need the device name typed as confirmation; MCP can only dry-run them.
  • Power and input changes: at most one per 5 s per device (429). Every POST writes one audit line to stdout.
  • Responses carry a strict CSP (script-src 'self', no inline script or style, frame-ancestors 'none') and X-Frame-Options: DENY.
  • MCP on MCP_PORT requires Authorization: Bearer $TV_CONTROL_MCP_TOKEN; without a token it answers 503.
  • tv-control never disconnects devices from the shared ADB server, powers a Shield off, or wipes or removes apps. Static tests guard this.

Configuration

Variable Default Purpose
TV_PROXY_KEY (unset) Shared secret the reverse proxy sends as X-TV-Proxy-Key. Required for the panel.
TV_CONTROL_MCP_TOKEN (unset) Bearer token for /mcp. Required for MCP.
TV_PANEL_URL the panel URL Public panel URL, shown in error messages.
TS_IP (unset) Extra bind address (for example a Tailscale IP) next to 127.0.0.1. Bound with IP_FREEBIND, so it may come up later.
PANEL_PORT 8011 Panel and /healthz.
MCP_PORT 8012 MCP streamable HTTP at /mcp.
DEVICES_FILE /scripts/devices.yml Device inventory.
SCRIPTS_DIR /scripts Optional deploy-config.sh, firetv-settings.sh, and lockdown.sh for the baseline and lockdown verbs. Not shipped here.
HOST_CONFIG_DIR /config Host path of the /config volume, printed for CLI screenshots.
REMOTE_DIR /config/remote Android TV Remote v2 client certificates and pairing markers, one set per Shield.
APP_CACHE_FILE, SLEEP_TIMERS_FILE, UNDO_DIR, ENROLL_DIR, SCREENSHOT_DIR, WEBOS_KEYS_FILE, WOL_KEY_FILE under /config State files.

tv-control runs adb against the server on 127.0.0.1:5037. If another container shares that server, keep both on the same adb version, or each client restarts the server.

devices.yml

See devices.example.yml. Keep the real file outside this repo and mount it read-only.

Key Purpose
devices[].name Unique device name, used by every verb.
devices[].type shield, firestick, firetv, or webos.
devices[].site Site key; must exist under sites. Drives tv-site-<site> access.
devices[].ip, port Primary ADB (5555) or webOS address.
devices[].tailnet_fqdn Fallback address, tried when ip fails.
devices[].capabilities Any of find_remote, wake, power, inputs, sleep_timer.
devices[].label, mac, tv_control, status Display name, WoL MAC, best-effort CEC, pending (skipped until it has an address).
devices[].inputs Input key to button label, for example HDMI_1: Shield.
devices[].input_ids Fire TV only: input key to the Android TvInputInfo id (from adb shell dumpsys tv_input). An input without an id is shown as unavailable.
devices[].paired_tv The device name of the TV a Shield feeds; drives the paired-TV health status.
denied_apps Substrings of package names or titles that are never launched.
quick_apps Quick-launch buttons: label, id list, optional title list.
sites.<site>.cidr Private LAN that the scan probes.
sites.<site>.wol_relay user@host that sends WoL for that site over SSH.
type_profiles.<type> Read only by the optional SCRIPTS_DIR scripts: debloat_lists, cec_on (Global settings set to 1), and keep_apps (never disabled). tv-control ignores it.

Compose example

services:
  tv-control:
    image: ghcr.io/baker-scripts/tv-control:latest
    container_name: tv-control
    network_mode: host
    user: "1000:1000"
    read_only: true
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    tmpfs: [/tmp]
    environment:
      TV_PROXY_KEY: ${TV_PROXY_KEY}
      TV_CONTROL_MCP_TOKEN: ${TV_CONTROL_MCP_TOKEN}
      TV_PANEL_URL: https://tv.example.com
      HOME: /config
    volumes:
      - ./devices.yml:/scripts/devices.yml:ro
      - ./config:/config
    restart: unless-stopped

HOME=/config keeps the ADB key (/config/.android/adbkey) on the volume. Point the reverse proxy at 127.0.0.1:8011 (or TS_IP:8011) and have it set X-TV-Proxy-Key.

Pair fast keys (Shield)

  1. Open the panel as a tv-admin member. The Shield must be reachable on its ip (Remote v2 uses TCP 6466 and 6467).
  2. On the Shield card, press "Pair fast keys". The Shield shows a code on screen.
  3. Type the code into the prompt. The certificate is saved under REMOTE_DIR, and later key presses use Remote v2.

Wake, sleep, and power stay on ADB, so they work while the Shield's remote service is down. To re-pair, delete that Shield's files under REMOTE_DIR and repeat the steps.

Development

uv sync --locked
uv run --locked ruff check . && uv run --locked ruff format --check .
uv run --locked mypy .
uv run --locked pytest             # unit tests, 80% coverage gate
uv run --locked playwright install chromium
uv run --locked pytest -m e2e --no-cov   # Playwright smoke at 375 px and 1280 px

Tests need yq (mikefarah/yq) on PATH. Set DEVICES_FILE to lint your own inventory with tests/test_lint.py.

License

GPL-3.0. See LICENSE.

About

Web panel and MCP server for Android TV (Shield, Fire TV) and LG webOS TVs

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages