โ† Kevin Yoder
Personal, Family & Home

Roku LAN Remote

A phone-friendly browser remote for the household's Roku TVs โ€” SSDP discovery and Roku ECP over the LAN, with devices keyed by hardware serial so a new DHCP lease never breaks the buttons.

Running on home LAN Built Feb 2026 LAN-only Serial-keyed device tracking

โš™ How it works ๐Ÿ“Š Results ๐Ÿ–ผ Screenshots

01 Overview

A small self-hosted web app that turns any phone or laptop browser into a working remote for the Roku TVs on the home network. It is a single-card UI backed by a small server that finds Rokus on the LAN via SSDP and controls them over Roku's External Control Protocol (ECP) โ€” the same HTTP interface the physical remote's mobile app uses. It offers 21 remote commands (power, volume, D-pad, playback), 13 quick-launch channels, and a device list that persists to a JSON file keyed by hardware serial number. It runs as one of the tiles on my home-server dashboard, alongside the other self-hosted services.

02 Why I built it

It started as a convenience remote โ€” one web page that could drive the family's Rokus without hunting for the plastic clicker. The part that made it interesting came from a real failure. One day the buttons silently stopped working: the UI still rendered, but every command failed. The cause was mundane and instructive โ€” the TVs had picked up new DHCP leases, and the app was keyed on IP address, so the moment an IP changed, control broke. Fixing that properly is what turned a throwaway remote into something with a bit of engineering in it.

03 What I built & how it works

A React front end, an Express + tRPC server, and a device model that treats the IP address as disposable.

Browser React 19 ยท shadcn/ui ยท wouter tRPC over HTTP ยท superjson Express + tRPC ยท rokuRouter home server :8094 ยท host network discover ยท sendCommand ยท launchChannel ยท addDevice roku.ts SSDP M-SEARCH ยท UDP 1900 ยท 3s window fallback: scan known IPs ECP GET/POST ยท regex XML parsing rokuDeviceStorage.ts roku-devices.json serial-keyed upsert IP = mutable metadata ECP over HTTP Roku TV :8060 ยท ECP control endpoint

Fig. 1 โ€” the server discovers and resolves; SSDP multicast is why the container runs on host networking; the IP is stored only as mutable metadata under each device's serial.

  1. Pick a TV โ€” the UI addresses the command by the device's serial number, never its IP.
  2. Fast path โ€” the server tries the last-known IP, fetches /query/device-info, and confirms the serial still matches.
  3. Slow path โ€” on a mismatch or a dead address it re-runs discovery (SSDP plus a known-IP scan), matches by serial, and updates the stored IP.
  4. Send โ€” it POSTs the keypress or channel launch to the resolved address over ECP, and surfaces any failure as a toast.
  5. Remember โ€” a successful command refreshes lastSeen, so the next press takes the fast path again.

04 ๐Ÿ›  Skills & tech used

Languages
TypeScriptBashSQL (Drizzle migrations)
Networking / protocols
SSDP multicast (node-ssdp)Roku ECP over HTTPUDP 1900 discoveryDHCP-resilient identityregex XML parsing
Backend & data
ExpresstRPC 11Zod-validated proceduresdrizzle-orm / mysql2JSON-file persistence (upsert)esbuild bundlingsuperjson
Frontend
React 19Tailwind 4shadcn/ui + RadixwouterTanStack Query (via tRPC)sonner toastslucide icons
Infra / ops
Docker (node:22-alpine, multi-stage)docker-compose ยท host networkmem_limit 256mhealthcheckgethomepage tilebackup integration
Techniques
serial-over-IP identity keyingfast-path / slow-path resolutionfallback-chain discovery.bak snapshots before refactorsVitest (auth path)

05 Notable challenges & decisions

A small utility, but the DHCP problem forced one genuinely worthwhile design choice.

Identity

DHCP breaks anything keyed on IP

The original app stored and addressed each TV by its IP address, so a routine DHCP lease change made every button fail while the UI kept working. The fix was to make the hardware serial number the primary key end to end โ€” UI selector, tRPC input, storage, and resolver all speak serial. The IP became mutable metadata that the resolver re-discovers and rewrites whenever it goes stale. Nothing about the network had to become static.

Networking

SSDP multicast forces host networking

Discovery relies on an SSDP M-SEARCH over UDP multicast (port 1900), which a bridged Docker network doesn't pass cleanly, so the container runs with network_mode: host. That was a deliberate, documented tradeoff โ€” and it has a real cost: the service permanently claims port 8094 on the host, a constraint later services had to route around.

Discovery

SSDP alone is not reliable enough

SSDP sometimes returned nothing within its 3-second window, so discovery is a fallback chain rather than a single method: after the multicast search it scans the set of known device IPs, seeded so a first run has somewhere to look. The presence of that seed and the short timeout are honest evidence that discovery flakiness was hit in practice, not theorized.

Honest scars

A database table the app never uses

An early design cached devices in a MySQL table; the Drizzle schema and migrations for it still exist, but the running app persists to a plain JSON file instead. The table is dead code, and its DATABASE_URL is a leftover that would never even connect. I've left it visible rather than tidied away โ€” it's an accurate record of a design that changed mid-build.

The best fix was choosing a better key. The whole DHCP failure dissolved the moment identity moved from IP to serial number โ€” no static leases, no polling service, no extra moving parts. A small change to the data model did the work a pile of networking code would have.

06 Results

21
remote commands
13
quick-launch channels
3s
SSDP discovery window
2s
ECP request timeout
~500
lines of first-party server logic
~5mo
continuous LAN service

Sources: server source (roku.ts, rokuChannels.ts), the compose service definition, and the container's uptime as of late July 2026. The line count is the core Roku logic across three server files, excluding the generated template scaffolding.

07 Screenshots

Captured 2026-08-02 from the verified runnable clone (Steps to Build tab), which seeds invented demo devices โ€” the live app's real household serial numbers are never shown.

The Roku Controller: one dark card with a device dropdown, power/volume buttons, a D-pad with OK, a playback transport row, and a Quick Launch grid of streaming channels.
The whole app is one screen: choose a TV by name and model, then drive it โ€” the dropdown is keyed on serial, so the IP is never shown or needed. (Devices here are the clone's invented "Living Room / Bedroom / Kitchen" demos.)
[ add-device dialog ]
IP entry field + a live connection-test toast
Manually adding a Roku by IP: the field is regex-validated and the server tests the connection before the device is saved.
[ server logs ]
SSDP discovery lines, then a "stored IP stale → rediscovering"
resolve-by-serial trace
The DHCP-resilience story in the logs: a stale address detected, a rediscovery by serial, and the stored IP quietly rewritten.

08 Honest status

Working, and running as a Docker container on my home server's host network (port 8094), in continuous LAN service since February 2026. It is deliberately LAN-only with no authentication โ€” every procedure is public, scoped to a trusted home network โ€” so it should not be exposed to the internet as-is: anyone who could reach it could control the TVs and read device serials and MAC addresses. For the record, this one was built with the Manus AI coding agent rather than Claude; the point of this rรฉsumรฉ is AI-assisted work in general, so it belongs alongside the rest. It also still carries a fair amount of unused scaffolding from that template โ€” the abandoned database table, plus generated UI and service modules โ€” which I left in place rather than spend time pruning a home utility down to just the Roku code. Test coverage is thin: there is one template auth test and no Roku-specific tests. It is a modest, useful appliance, not a hardened product โ€” but the serial-keyed device model is a real fix that has kept it working through every lease change since. Update (2026-08-02): a runnable, de-identified clone (Steps to Build tab) now exists โ€” the real Roku logic plus a small mock-Roku service that answers the device's control endpoints, so the whole thing demos with no hardware and no real device data. It was verified end-to-end: selecting a seeded demo TV and pressing a button routed the command through to the mock.

roku-remote โ€” Build Recipe

Take a bare machine to a running web-based Roku remote: a phone-friendly single-card UI backed by an Express + tRPC server that discovers Roku devices, controls them over Roku's External Control Protocol (ECP), quick-launches 13 streaming channels, and tracks each device by serial number so control keeps working even after the TV's DHCP IP changes. Runs with no real hardware, no keys, and no SSDP multicast โ€” a small bundled mock Roku plays the part of the TVs so the whole thing is demoable on any Docker host.

Status: โœ… Verified 2026-08-02 โ€” built from this clone on a Linux Docker host (docker compose up -d --build). Both services came up healthy; the web app served on :8094 and its seeded fake devices (Living Room / Bedroom / Kitchen) populated the picker with no real hardware and no SSDP. A real-browser pass selected a device and pressed Home, and the mock Roku received the ECP keypress end-to-end (Living Room Roku <- keypress Home), with 0 console errors. All three tiers covered; the prebuilt tarball (docker save roku-remote-clone roku-remote-mock-clone) is ~430 MB.

Sensitive data: none. The four real household Rokus (names, LAN IPs, serials, MACs) were replaced with invented devices; the Manus OAuth/JWT scaffolding was stripped; the app runs key-free and device-free.

Sensitive data: none in this clone. The personal version stored four real household Rokus (names including a family surname, LAN IPs, serial numbers, MAC addresses) in a device file, carried a hardcoded JWT_SECRET, and pointed at the Manus OAuth platform. All of that is replaced with three invented demo devices served by the mock, and every Manus/auth/database scaffold was removed or neutralized. See What was stripped.


What it is

Two small services on one compose network:

Service Role
roku-controller (repo root) the product โ€” a React 19 + tRPC 11 + Express app. Discovers devices, sends the 21 remote commands + 13 channel launches over ECP, persists devices in a serial-keyed JSON file, serves the remote UI on :8094.
mock-roku (mock-roku/) a stand-in for real Roku TVs. Answers the three ECP endpoints the app calls (/query/device-info, /keypress/:cmd, /launch/:id) for three fake devices โ€” so the clone needs no TV, no LAN multicast, and no host networking.

Stack: TypeScript throughout, tRPC over HTTP (superjson), node-ssdp for discovery, axios for ECP, Tailwind 4 + shadcn/ui for the single remote card. No GPU, no database, no API keys.

Prerequisites

New machine? See ../SETUP.md for Docker. Then, per tier:

Tier You need
1 โ€” prebuilt containers (recommended) Docker + this bundle's clone/ folder (for the compose file).
2 โ€” build from source Same as Tier 1.
3 โ€” bare-metal Node 20.11+ (for import.meta.dirname; Node 22 recommended) (two processes: the app and the mock).

Configuration โ€” nothing required

Runs key-free and device-free out of the box. The three demo Rokus are seeded into the device store on first boot, and their serial numbers match what the mock reports โ€” so the app's serial-keyed fast path resolves them live with no real TV. Everything is env-overridable (clone/.env.example), but the defaults in docker-compose.yml need no editing.


Tier 1 โ€” Run the prebuilt containers (recommended)

Availability: the prebuilt image bundle is available on request โ€” it is not published or linked anywhere. Ask Kevin for it, or build from source via Tier 2 below.

docker load -i roku-remote-clone-image.tar   # loads both images
cd clone
docker compose up -d                         # โ†’ http://localhost:8094

Tier 2 โ€” Build from source

cd clone
docker compose up -d --build                 # โ†’ http://localhost:8094
curl -s localhost:8094/health                # โ†’ {"status":"ok"}

Open http://localhost:8094. The device dropdown is already populated with the three demo Rokus. Pick one, then press buttons โ€” the mock logs each keypress (docker compose logs -f mock-roku), and the UI toasts success/failure exactly as it would against a real TV.

Tier 3 โ€” Bare-metal (two Node processes)

cd clone

# 1) the mock Roku (answers ECP on :8060)
cd mock-roku && npm install --omit=dev && node server.js &      # โ†’ :8060
#   The three demo devices are addressed by hostname; for a single-host bare-metal
#   run, point one seed device at localhost (see note below).

# 2) the controller
cd ..
npm install --legacy-peer-deps
npm run build                                                    # vite + esbuild
ROKU_SSDP_ENABLED=false \
ROKU_SEED_HOSTS=127.0.0.1 \
ROKU_STORAGE_PATH=./data/roku-devices.json \
PORT=8094 npm start                                              # โ†’ http://localhost:8094

On bare metal the compose network aliases (roku-bedroom, โ€ฆ) don't exist, so the mock's three devices all answer on 127.0.0.1:8060 and the app resolves whichever one the Host header selects (it defaults to the living-room device). The Docker path is the honest multi-device demo; bare-metal is the "it's just two Node processes" proof. On Windows, set the env vars with $env: first.

Optional: point it at your real Rokus

This is the whole reason the project exists. To drive actual TVs instead of the mock:

  1. Run the controller with host networking (so it can send SSDP multicast on UDP 1900) and enable discovery: yaml # docker-compose.override.yml services: roku-controller: network_mode: host environment: - ROKU_SSDP_ENABLED=true (Drop the mock-roku service and the ports: mapping when using host networking.)
  2. Or skip SSDP entirely and list your TVs' LAN IPs: ROKU_SEED_HOSTS=192.168.1.50,192.168.1.51, or add them one at a time with the + button in the UI (enter the IP; it connects, reads the serial, and saves it).

Either way ROKU_ECP_PORT stays at the Roku default of 8060. Once a device is stored by serial, its IP can change freely and control still works.

Provenance โ€” data & devices

No datasets, no models, no keys. mock-roku/server.js and seed/roku-devices.json contain three invented devices with fake, self-consistent names, models, serial numbers, and MAC addresses (DEMOROKU0001โ€ฆ3, aa:bb:cc:00:00:0x). The channel IDs in shared/rokuChannels.ts are Roku's public, well-known app IDs (Netflix 12, YouTube 837, Jellyfin 592369, โ€ฆ) โ€” not personal data.

What was stripped from the personal version (de-identification)

  • The real household Rokus โ€” the live device file held real device names (one revealing a family surname), LAN IPs, serial numbers, and MAC addresses, and the discover/addDevice API returned them to any LAN client with no auth. Replaced entirely with the three invented mock devices.
  • The hardcoded seed IP baked into the discovery fallback (a fixed real home-LAN address) โ†’ replaced with an env-driven ROKU_SEED_HOSTS list (empty by default, pointed at the mock in compose).
  • A hardcoded JWT_SECRET and the Manus OAuth platform dependency โ†’ removed; the clone has no auth layer and makes no external calls. (The app is LAN-only by design; see limitations.)
  • The dead database layer โ€” a drizzle-orm/mysql2 schema plus a bogus DATABASE_URL=file:/app/data/roku.db (a SQLite URL pointed at a MySQL driver that never connected). Persistence was always the JSON file; the DB code and env var are gone.
  • The Manus template scaffolding the app was generated inside โ€” the unused _core LLM / voice / map / image-generation modules, the OAuth/JWT session plumbing, the vite Manus-runtime plugin and 800-line browser debug collector, and ~45 unused shadcn components / ComponentShowcase / demo pages. Kept only the shadcn primitives the remote actually uses (button, card, dialog, input, label, select, sonner, tooltip).
  • The hardcoded ECP port (:8060 in three places) โ†’ parameterized via ROKU_ECP_PORT so the app can be aimed at the mock, and SSDP was made opt-in (ROKU_SSDP_ENABLED, default off) so the clone runs on a plain bridge network instead of requiring host networking.
  • Added a plain GET /health route (the original's healthcheck curled one that only existed as a tRPC procedure).

Known limitations (stated honestly)

  • No authentication โ€” like the original, every procedure is public. This is a trusted-LAN tool; don't expose it to the internet as-is (anyone who reaches it could control the TVs and read device serials/MACs).
  • The mock is a stand-in, not a Roku. It returns valid ECP device-info and 200s every keypress/launch, but there's no on-screen effect to see โ€” the demo proves the control path (discovery โ†’ serial resolution โ†’ ECP call), not a picture on a TV. The real payoff is pointing it at hardware.
  • Bare-metal collapses the three devices onto one host (127.0.0.1:8060), since the multi-device illusion relies on docker-compose network aliases. Use the Docker path for the full three-device dropdown.
  • Search is wired but has no button, and removeRokuDevice exists with no UI โ€” both carried over faithfully from the original as-is.

The engineering story (why this is worth showing)

The signature lesson came from a real failure: button presses silently stopped working because the Rokus picked up new DHCP leases, and the app had been keyed on IP address. The fix โ€” carried into this clone verbatim โ€” is to make the serial number the stable identity end to end:

  • The UI addresses commands by serialNumber, never IP (the dropdown doesn't even show IPs).
  • resolveDeviceIP(serial) runs a fast path / slow path: try the stored IP and verify it still reports the expected serial via device-info; if it's stale, run full re-discovery, match by serial, update the stored IP, then send the command. So a TV that moved to a new IP is silently re-found on the next press.
  • Discovery itself is a fallback chain: SSDP M-SEARCH roku:ecp (filtered by the Roku SERVER header) when enabled, degrading to a known-host scan and a seed list. ECP XML is parsed by a handful of regexes โ€” no XML library โ€” because only four fields matter.

The mock exists to make all of that observable without a TV: three fake devices behind docker-compose network aliases let the controller resolve each one by host exactly as it would by IP on a real LAN, so the serial-keyed fast path, the stale-IP re-resolution, and the ECP request/response are all exercised end to end, key-free and hardware-free.

The original was generated with the Manus AI agent (not Claude); this clone keeps the first-party Roku logic and rebuilds everything around it as a clean, self-contained, key-free project.