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