Docs
/

@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

RokuVizio SmartCastGoogle Cast
protocolECP, plain HTTP :8060HTTPS :7345 / :9000, self-signedHTTP :8008 (DIAL)
pairingnonePIN → tokennone
remotefullnav, volume, channel, powernone
play / pauseone toggle✗ no confirmed key
text input✓ one Lit_ per char✗ no endpoint
app listread off the devicecatalog onlycatalog only
inputs✓ (cycles — no direct jump)
wakeplayer: 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.

@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.

Generated from the Leumas repository. Every page cites the file it came from.leumas.techllms.txt