@leumas/tv
Every smart television on the operator's LAN, behind one vendor-neutral contract. A driver declares what a brand can do — discover, identify, drive a remote, list and launch apps, type, power, wake...
@leumas/tv
Every smart television on the operator's LAN, behind one vendor-neutral contract. A driver declares what a brand can do; the surface, the API, the adapter and the remote all read that declaration instead of naming a vendor.
Adding a brand is one folder. Roku, Vizio SmartCast and Google Cast ship as drivers.
Studio · Devices → TVs an agent · MCP client · workflow node
│ │
│ POST /api/tv/... │ POST /api/adapters/tv/<verb>
▼ (requireAuth, unmetered) ▼ (requireAuth, METERED)
createTvRouter shared/engines/adapters/domain/tv
└────────────────┬────────────────────┘
▼
@leumas/tv
vocab · contract · registry · discover
│
┌────────────────┼────────────────┐
roku vizio googlecast
(ECP, open) (HTTPS, paired) (HTTP, partial)
Why this is not in @leumas/capabilities
That package is the provider capability namespace — weather.current, ai.text.generate — and its own header says, in capitals, that "capability" already means four things in this process and warns against adding a fifth. All four are about reaching something over the internet with a credential. A television on the operator's own LAN is none of them: no account, no OAuth, no public endpoint, and the SSRF guard that protects every provider call would correctly refuse its address.
The seam that already existed is defineDeviceDriver in @leumas/devices — "DRIVE a device Leumas actually talks to. Has a lifecycle." A television is a row in the same devices collection as a printer, with driver naming the vendor. There is no television table.
Writing a driver
import { defineTvDriver } from '@leumas/tv/contract';
export default defineTvDriver({
id: 'acme',
label: 'Acme TV',
needsPairing: false,
notes: { remote: 'Acme sets have no volume control over the network.' },
identify: async (address) => ({ ok: true, device: {/* … */}, reason: '' }),
actionsFor: (device) => ['home', 'up', 'down', 'left', 'right', 'select'],
command: async (device, { action }) => ({ ok: true, reason: '', ms: 4 }),
});
Then one line in src/drivers/index.js. Nothing else changes.
can is DERIVED, never declared. A driver cannot advertise a capability it did not implement — the claim and the implementation are the same fact, so a UI that draws an Apps section from can.apps cannot then find no apps() to call.
Every verb resolves. { ok, …, reason }. The registry wraps every call and turns a throw into a refusal, because the first thing a new driver does is call fetch and fetch rejects on a closed socket — but a driver should not rely on that.
A driver never touches the database or takes an address from a caller. Discovery hands it addresses it found itself; every other verb gets a stored record. Without that asymmetry a command verb taking an address is an authenticated outbound proxy onto the operator's LAN.
What each shipped driver can actually do
| Roku | Vizio SmartCast | Google Cast | |
|---|---|---|---|
| protocol | ECP, plain HTTP :8060 | HTTPS :7345 / :9000, self-signed | HTTP :8008 (DIAL) |
| pairing | none | PIN → token | none |
| remote | full | nav, volume, channel, power | none |
| play / pause | one toggle | ✗ no confirmed key | ✗ |
| text input | ✓ one Lit_ per char | ✗ no endpoint | ✗ |
| app list | read off the device | catalog only | catalog only |
| inputs | ✗ | ✓ (cycles — no direct jump) | ✗ |
| wake | player: no · TV: yes | ✓ real power key | ✗ |
The gaps are declared, not discovered. Cast implements no command, so can.command is false, so nothing above draws a remote for it — and contract.test.js asserts that, so the day somebody adds a command that guesses at CASTV2 they have to mean it.
apps reports inventory: 'device' or 'catalog'. Only Roku can be asked what it has installed; a catalog entry may simply not be there, and launching it succeeds and does nothing. A surface must not present the two the same way.
The Vizio traps
- A wrong PIN comes back as HTTP 200. The failure is inside
STATUS.RESULT, so a client that reads
the status code reports a successful pairing and stores an empty token.
- Certificates cannot be verified. Every SmartCast set presents a self-signed certificate for a name
that is not its address; the choice is not "verified or not", it is "reach the television or not". The agent is scoped to one host — NODE_TLS_REJECT_UNAUTHORIZED=0 would disable verification for every outbound request in the process, including the provider layer's.
- The key table is community-derived. Vizio publishes no protocol docs. Only the well-attested
pyvizio pairs are used; anything else is absent rather than guessed, because a guessed codeset returns 200 and does nothing.
Roku's Limited mode
[critical] Found on real hardware after every fixture passed.
A Roku whose Control by mobile apps → Network access setting is restricted answers /query/device-info perfectly — so it is discovered, named, identified by serial and listed with a full capability set — and then refuses everything else with:
403 ECP command not allowed in Limited mode.
Reporting that as "did not accept that command" is accurate and useless: the operator has a television that appears to work, a remote whose every button fails, and no way to guess the cause is three menus deep. describeEcpFailure reads the body and names the setting; restricted travels separately from offline, because the set is healthy and greying it out would send somebody to check a working TV.
Testing
node --test test/*.test.js # 42 tests — contract, registry, Vizio, Cast
pnpm smoke:tv # the slice through the real API, 44 checks
The fakes are real servers: a genuine https.Server with a self-signed certificate speaking real SmartCast (it refuses everything but power state until paired, answers 200-with-a-failure-body for a wrong PIN, and blocks after three), and a genuine http.Server speaking DIAL. A stubbed client would exercise none of the TLS handling, the STATUS.RESULT reading or the pairing state machine — which is to say none of the code that can be wrong.
Every suite passes sweep: 'never'. A test must never touch a real network — and that is not theoretical: a poller test without it swept the developer's own /24, found their actual television, and failed on an assertion about somebody's living room.
Changing it
pnpm check:adapters check:devices check:nav, pnpm smoke:tv, and this package's own node --test test/*.test.js. Touching the Studio surface adds check:ui-kit check:theme check:surfaces.
Related
@leumas/roku-sdk — the ECP protocol, the identity ladder and the registry helpers this layer builds on · @leumas/devices — the device record and driver contract · leumas-network for the network itself.