โ 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.
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.
- Pick a TV โ the UI addresses the command by the device's serial number, never its IP.
- Fast path โ the server tries the last-known IP, fetches
/query/device-info, and confirms the serial still matches. - 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.
- Send โ it POSTs the keypress or channel launch to the resolved address over ECP, and surfaces any failure as a toast.
- Remember โ a successful command refreshes
lastSeen, so the next press takes the fast path again.
04 ๐ Skills & tech used
05 Notable challenges & decisions
A small utility, but the DHCP problem forced one genuinely worthwhile design choice.
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.
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.
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.
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
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.
IP entry field + a live connection-test toast
SSDP discovery lines, then a "stored IP stale → rediscovering"
resolve-by-serial trace
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:8094and 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:
- 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 themock-rokuservice and theports:mapping when using host networking.) - 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/addDeviceAPI 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_HOSTSlist (empty by default, pointed at the mock in compose). - A hardcoded
JWT_SECRETand 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/mysql2schema plus a bogusDATABASE_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
_coreLLM / 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 (
:8060in three places) โ parameterized viaROKU_ECP_PORTso 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 /healthroute (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-infoand 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. Searchis wired but has no button, andremoveRokuDeviceexists 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 viadevice-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 theRokuSERVER 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.