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


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

```js
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.


---
Source: shared/engines/tv/README.md
Canonical: https://docs.leumas.tech/p/engines/tv
