Version: protocol 1 · 2026-06-09. This document is the single source of truth for the
client ↔ server contract. It replaces the former protocol.md, connection-api.md
(this repo) and wire-format.md (dish-android). Both ends implement against this file.
Principle: control plane and data plane are split.
- Control = HTTPS REST on 9443, declarative full-state. The client PUTs its complete desired state; the server converges and returns the applied state. Retries are free, ordering is irrelevant. UDP never mutates topology.
- Data = UDP streams on 9876: input, heartbeat, motion, battery, touchpad, controller mic audio up; heartbeat ack, rumble, lightbar, trigger effects, player LEDs, controller speaker audio, mic LED, session-close notify down.
One user action = one call. Partial success rides in the response body (per-controller results), never in HTTP error codes.
| Port | Transport | Audience | Purpose |
|---|---|---|---|
| 9443 | HTTPS (TOFU-pinned self-signed TLS) | LAN clients | Pairing, sessions, controllers, catalog, capabilities |
| 9876 | UDP (ChaCha20-Poly1305) | LAN clients | Input/telemetry streams + authenticated notifications |
| 9877 | HTTP, loopback only | Operator | Admin web UI + admin API (never client-facing) |
| 9879 | UDP broadcast | LAN clients | Legacy discovery beacon (fallback; passive on the client) |
| 5353 | mDNS _satellite._udp |
LAN clients | Primary discovery; TXT carries mid (machineId), pair (9443) |
There is no client-facing TCP pairing port and no client-facing HTTP API on 9877.
- Satellite identity (as seen by clients):
machineIdonly. The stable id the satellite broadcasts in mDNS TXT / beacon. Clients MUST key remembered satellites on machineId alone, never on ip/port. - Client identity:
deviceId. A stable per-install UUID. Pairing plushmacProof(below) make it more than a bearer id.
A PairedDevice is {deviceId, deviceName, pairingKey} persisted on the satellite.
The 32-byte pairingKey is minted by the server and handed to the client exactly once
over TLS.
Two paths, both PIN-gated. There is no PIN-free path: a request for an
already-paired deviceId without a valid hmacProof is rejected (the historical
"already paired → hand back the key" short-circuit allowed any LAN actor who learned a
deviceId to exfiltrate the key, and is deleted).
Path A, operator PIN (server-generated, typed into the client):
{ "deviceId": "...", "deviceName": "...", "pin": "1234", "protocolVersion": 1 }
→ 200 { "ok": true, "message": "paired successfully", "sharedKey": "<64-hex>", "protocolVersion": 1 }Path B, client PIN (client-shown, operator approves on the satellite):
{ "deviceId": "...", "deviceName": "...", "clientPin": "5678", "protocolVersion": 1 }
→ 200 { "ok": false, "pending": true, "message": "awaiting approval on the satellite" }The client then polls until the operator acts.
A Path-A request MAY additionally carry the client's X25519 public key; the pairing key is then derived on both ends instead of being transported:
{ "deviceId": "...", "deviceName": "...", "pin": "1234", "publicKey": "<64-hex>", "protocolVersion": 1 }
→ 200 { "ok": true, "message": "paired successfully", "serverPublicKey": "<64-hex>", "protocolVersion": 1 }- Keypairs are libsodium
crypto_kx(X25519). The server computescrypto_kx_server_session_keys(rx, tx, serverPk, serverSk, clientPk)and persistsrxas the 32-bytepairingKey; the client computescrypto_kx_client_session_keys(rx, tx, clientPk, clientSk, serverPk)from the response'sserverPublicKeyand usestx(clienttxequals serverrxby construction). - On success the response carries
serverPublicKeyINSTEAD ofsharedKey: the long-lived trust root itself never transits the wire. - An unusable
publicKey(malformed hex, wrong length, low-order point) fails the pair with{"ok":false,"error":"invalid public key"}without consuming the operator PIN; it is never silently downgraded to a server-minted key. - Scope: honored on the operator-PIN path only. Path-B approval
(
GET /api/pair/status) and proof-based rotation always returnsharedKey(apublicKeyfield is ignored there). - Baseline unchanged: this extension is OPTIONAL and additive. Every shipping
client pairs via the server-minted
sharedKeyover TLS (above), and the server serves exactly that whenever nopublicKeyis present.
Path-B poll. Responses: {"ok":true,"status":"approved","sharedKey":"<64-hex>"} exactly
once (the staged key is single-use), else {"ok":false,"status":"pending"|"none"}.
There is no denied status: an operator deny erases the pending request, so a denied
client polls straight to none and must treat it as terminal for that attempt.
{ "deviceId": "...", "deviceName": "...", "hmacProof": "<64-hex>", "protocolVersion": 1 }
→ 200 { "ok": true, "message": "key rotated", "sharedKey": "<64-hex new key>" }The proof MUST verify against the current key. A failed proof falls through to the
PIN paths (i.e. behaves exactly like a fresh pairing attempt). Rotation closes any live
session for the device (close-notify reason replaced first).
- Client self-unpair:
DELETE /api/pairwith headersX-Device-IdandX-Hmac-Proof(body{"deviceId","hmacProof"}also accepted). 200{"ok":true}; 401 on bad proof. - Admin:
DELETE /api/devices/{deviceId}(loopback 9877).
Both close any live session for the device, sending close-notify 0x000F
(reason unpaired) before teardown.
hmacProof = hex( HMAC-SHA256( key = pairingKey, message = "satellite-proof:" + deviceId ) )
(libsodium crypto_auth_hmacsha256.) Sent on every authenticated REST call, in the
X-Hmac-Proof header (alongside X-Device-Id) or the JSON body field hmacProof
(header wins when both are present). TLS keeps it confidential; its purpose is proof of
key possession, so a client whose key diverged from the server's fails at REST time
with 401 instead of producing a silently-undecryptable UDP session.
401 body carries a machine-readable code the client maps to UI:
{ "error": "unauthorized", "code": "NOT_PAIRED" | "BAD_PROOF" }Either code is terminal: the client MUST stop retrying and surface "re-pair needed". 503 means the server is shutting down (retry later is acceptable).
At most one session per deviceId, enforced by construction (the session row is keyed on deviceId). Zero-controller sessions are valid: a user sitting in menus is connected with no pads.
Connect and declare the full topology in ONE call; re-PUT converges. The connection row
never churns: connectionId is stable across reconnects, token rotates per PUT.
Request:
{
"deviceId": "...",
"deviceName": "Pixel 9",
"protocolVersion": 1,
"hmacProof": "<64-hex>",
"controllers": [
{
"ctrlIdx": 0,
"type": 0,
"caps": { "rumble": true, "motion": true, "analogTriggers": true, "lightbar": false,
"triggerEffects": false, "playerLeds": false,
"mic": false, "speaker": false },
"touchpadMode": "off",
"preferredBackend": null
}
],
"hostFeatures": { "mouseControl": true }
}controllersis the COMPLETE desired set. Slots present on the server but absent from the array are unplugged. An empty array (or absent key) means "zero controllers".typeis a catalog id (see Catalog).touchpadMode∈"ds4" | "mouse" | "off".preferredBackend(additive;nullor absent = the host chooses) names a backend id fromcapabilities.backends[]. It is a hint, not a mandate: the host tries that backend first when it materializes the requestedtype, and otherwise falls through its normal preference order. There is deliberately no result code for "that backend refused" —backendin the response reports which one actually took the pad.hostFeaturesis the requested set; absent ={}. Requests are client-owned desired state; grants are server policy. Different fields, not a two-master conflict.
Response (200):
{
"connectionId": "conn_ab12cd34",
"token": "0007a1b2",
"sessionSalt": "<16-hex (8 bytes)>",
"epoch": 3,
"maxControllers": 16,
"protocolVersion": 1,
"controllers": [
{
"ctrlIdx": 0,
"result": "ok",
"appliedType": 0,
"backend": "vigem",
"motion": { "sinkSupportedForType": true, "backendOk": true }
}
],
"hostFeatures": {
"mouseControl": { "granted": true }
}
}Per-controller result codes (protocol constants, never localized):
ok, noSlots, pluginFailed, replugFailed, backendUnavailable, invalidType,
invalidIndex. On replugFailed the previous pad is left untouched and appliedType
reports the type still in force. Host-feature denials carry a structured reason the
client translates if it knows it: {"granted": false, "reason": "notSupported" | "backendUnavailable" | "denied"}.
backend (additive) is the id of the backend that materialized this pad, or null
when the slot isn't plugged. This is the ONLY place the routing decision is observable:
the host picks per plug, so two slots in one session can legitimately sit on different
backends, and a client that sent preferredBackend learns here whether it got it.
Host-input grants are a real privilege step (a phone that can move the mouse owns the
PC). v1 grants mouseControl whenever the backend supports it; per-device policy can
land later with zero protocol change.
Descriptor rules:
- ControllerDescriptor is always sent WHOLE. A rumble/motion/touchpad toggle is a re-send of the descriptor with one field changed; the server converges (replug only on a materialization-identity change, e.g. Xbox ↔ DS4 ↔ DualSense ↔ Switch Pro).
- Single-writer: descriptor fields are client-owned. The admin UI displays them but never sets them. Admin intent is session-level (kick) or trust-level (unpair) only.
Client-scoped to its OWN session (X-Device-Id + X-Hmac-Proof; 404 for someone
else's id). This is the reconcile endpoint: applied descriptors, epoch, liveness.
{
"connectionId": "conn_ab12cd34",
"deviceId": "...",
"epoch": 3,
"protocolVersion": 1,
"maxControllers": 16,
"controllers": [
{ "ctrlIdx": 0, "active": true, "appliedType": 0, "backend": "vigem",
"caps": { "rumble": true, "motion": true, "analogTriggers": true, "lightbar": false,
"triggerEffects": false, "playerLeds": false,
"mic": false, "speaker": false },
"touchpadMode": "off", "preferredBackend": null,
"motion": { "sinkSupportedForType": true, "backendOk": true } }
],
"hostFeatures": { "mouseControl": { "granted": true } }
}- From the client (authed): graceful close. No notify (the closer already knows).
- From the admin API (loopback): kick, close-notify reason
kickedfirst. Transient by design: a retrying client may re-PUT and reconnect. To keep a device out, unpair it.
- Create: via the session PUT
controllers[], or standalone upsert below. - Read: via the session GET (covers all slots).
- Update:
PUT /api/connections/{id}/controllers/{ctrlIdx}with the FULL descriptor (same JSON as onecontrollers[]element,ctrlIdxin the path wins). Converges exactly like the session PUT; response is that one controller's apply result plus the sessionepoch. - Delete:
DELETE /api/connections/{id}/controllers/{ctrlIdx}removes the SLOT only; the session lives on. Deleting the SESSION is what closes the connection.
Both standalone routes are authed (X-Device-Id + X-Hmac-Proof) and scoped to the
caller's own session.
{
"protocolVersion": 1,
"serverVersion": "1.6.0",
"maxControllers": 16,
"backend": { "id": "vigem", "supported": true, "available": true, "errorCode": null },
"backends": [
{ "id": "vigem", "vendor": "Nefarius Software Solutions", "displayName": "ViGEmBus",
"kernelMode": true, "audio": false, "available": true, "errorCode": null,
"lifecycle": "eol", "eolDate": "2023-11-02", "driverVersion": "1.22.0.0",
"bundledVersion": "1.22.0", "versionState": "current", "restartPending": false,
"controllers": [
{ "type": 0, "name": "xbox", "latency": "lowest", "latencyRank": 0,
"motion": false, "touchpad": false, "lightbar": false, "motionRequires": null,
"submitLatency": { "tier": "lowest", "rank": 0, "score": 2,
"nominalUs": 2, "tailUs": 5, "submitPath": "kernel-direct",
"facts": { "kernelCrossings": 1, "threadWakeups": 0,
"brokerHops": 0, "managedRuntime": false,
"pollIntervalUs": 0 } } },
{ "type": 1, "name": "playstation", "latency": "lowest", "latencyRank": 0,
"motion": true, "touchpad": true, "lightbar": true,
"motionRequires": "vigembus>=1.17", "submitLatency": { "...": "…" } }
] },
{ "id": "hidmaestro", "vendor": "hifihedgehog", "displayName": "HIDMaestro",
"kernelMode": false, "audio": true, "available": true, "errorCode": null,
"lifecycle": "supported", "eolDate": null, "driverVersion": "1.4.7.12",
"bundledVersion": "1.4.7.12", "versionState": "current", "restartPending": false,
"controllers": [
{ "type": 0, "name": "xbox", "latency": "medium", "latencyRank": 2,
"motion": false, "touchpad": false, "lightbar": false, "motionRequires": null,
"submitLatency": { "tier": "medium", "rank": 2, "score": 87,
"nominalUs": 36, "tailUs": 515, "submitPath": "usermode-shm",
"facts": { "kernelCrossings": 3, "threadWakeups": 2,
"brokerHops": 0, "managedRuntime": false,
"pollIntervalUs": 0 } } },
{ "type": 1, "...": "…" }, { "type": 2, "...": "…" }, { "type": 3, "...": "…" }
] }
],
"motion": { "available": true },
"host": {
"catalog": { "supported": true },
"mouseControl": { "supported": true, "available": true },
"keyboardControl": { "supported": false },
"rumble": { "supported": true, "available": true }
},
"controllerAudio": { "enabled": true, "mic": true, "speaker": true }
}motion.available reflects the motion backend right now (e.g. ViGEmBus new enough for
the DS4 EX report). backend.errorCode ∈ DRIVER_MISSING, BUS_OPEN_FAILED,
HELPER_MISSING, MODULE_NOT_LOADED, DEVICE_MISSING, PERMISSION_DENIED, or null.
backends (additive; absent on older servers) is the host's full backend option list,
most-preferred first — a host can carry more than one (Windows: vigem AND
hidmaestro; controllers route to the first available backend supporting the
requested type). The singular backend object stays the preferred-available backend
(or the most-preferred backend's error when none is available), so existing clients
keep working unchanged. Each entry's controllers lists the types that backend can
materialize, with its feature surface for that type (motionRequires is the structured
requires code for THAT backend, which is not necessarily the one the catalog quotes —
see the catalog layering rule below) and a derived submit-latency estimate. Ids vigem
/ hidmaestro / uinput / machid / none and all error codes are protocol
constants, never localized.
kernelMode describes that backend's INPUT submit path and nothing else. It stays
false for hidmaestro even when audio is true: input still goes through a user-mode
UMDF2 driver. audio (additive; absent on older servers) is the one runtime-switched
field on a backend entry, and it is what tells a reader a kernel component is in play.
It is true only when the backend has a controller type with audio endpoints AND the
host's controllerAudio setting is on, because materializing an audio-carrying
(composite) persona means the backend installs a bundled signed kernel USB transport on
first use. The per-controller mic / speaker columns are the static counterpart: what
that backend COULD materialize, unaffected by the setting (the catalog is cached on
server version + locale, so an install-time switch must not move it). A client that
reads only the columns would offer a microphone on a host that has switched audio off.
controllerAudio (top-level; additive, absent on older servers) is the host-level view
of the same switch, split by direction, so a client learns what will ACTUALLY flow
rather than what is merely configured. enabled is the host's controllerAudio
setting ANDed with whether ANY enumerated backend can carry audio at all, so a host
whose only backend has no audio-capable type reports false however the switches are
set. The per-backend audio field stays the place to learn WHICH backend can. mic
and speaker are the two per-direction settings ANDed with enabled, since neither
direction can flow through a persona that was never given audio endpoints. Those two gate the WIRE and not
the persona — the emulated pad keeps both Windows endpoints either way — which is why
they can change under a live stream, while enabled only takes effect at the next
plug. The block lives here and deliberately NOT in /api/catalog: that response's ETag
is server version + locale and must stay static identity, so no runtime setting may
reach it.
lifecycle ∈ supported | maintenance | eol is the UPSTREAM maintenance state of
the driver, carried with eolDate (ISO date, or null) and driverVersion (the version
the host actually read, or null when it cannot read one — never inferred, never
back-filled from a shipped installer's version). All three are protocol constants; the
copy a client renders for them is the client's own. A backend being eol says nothing
about whether it works — available is the runtime truth, and an eol backend may
well be the preferred one.
bundledVersion, versionState and restartPending (additive; absent on older
servers) are the install-state companions to driverVersion. bundledVersion is the
driver version the running server's own installer ships for that backend (null where it
ships none: uinput, machid, and any build without a pin). versionState ∈ current |
outdated | newer | unknown is driverVersion compared numerically against
bundledVersion, derived server-side so a client never parses version strings; it is
unknown whenever either side is null, including a missing driver (whose errorCode
already says so). restartPending is true when the driver was replaced on disk but the
loaded one is still the old one and Windows needs a restart to finish (ViGEmBus reports
this on its bus device node); it is false everywhere else. On Windows driverVersion is
the ViGEmBus.sys file version for vigem and the driver-store INF DriverVer for
hidmaestro (the HIDMaestro SDK release and its UMDF driver carry different version
schemes; the INF is what Device Manager shows and what a bumped pin changes).
host is the receiver's OWN capability inventory, readable before pairing or any
catalog round-trip so a client reflects the real receiver instead of an optimistic
default. Each entry's supported is the static fact (mirrors the catalog
hostFeatures / per-type features); available is a coarse runtime read. It means the
backend is up enough to accept controllers (bus open), NOT a per-feature delivery
probe, so a client can show a feature present-but-currently-down (e.g. driver
missing). catalog.supported is the presence signal: a server emitting host always
sets it true, so a client treats its absence as "older satellite, fall back to the
default". The block is additive: an older server omits it and the client degrades
gracefully. (host.rumble is the host return-channel; the per-type rumble feature
is a different layer, whether the emulated pad has a motor.)
submitLatency — and the flat latency / latencyRank it backs — is derived,
never typed. Those two flat fields keep their original string/integer types, so an
older client parses this payload unchanged; everything new rides alongside them in
submitLatency. The registry stores primitive facts about each
backend × type submit path and the server scores them, so a new backend is a row of
facts rather than a judgement call — and a type whose path differs from its siblings'
scores differently with no special case. HIDMaestro signals two events per frame for
Xbox (the input section plus the GIP companion) and one for every Sony/Switch pad, so
its xbox row ranks a tier worse than its own playstation row.
| Fact | Meaning |
|---|---|
kernelCrossings |
user→kernel transitions per submitted frame |
threadWakeups |
scheduler dispatches the frame waits on (the dominant tail term) |
brokerHops |
extra user-mode process hops on the hot path (0 for every current backend) |
managedRuntime |
GC/JIT anywhere on the per-frame path |
pollIntervalUs |
0 = the consumer is signalled; otherwise its sampling period |
Weights, in microseconds, applied to a typical frame and to a bad one:
| Unit | → nominalUs |
→ tailUs |
|---|---|---|
kernelCrossings |
2 | 5 |
threadWakeups |
15 | 250 |
brokerHops |
40 | 500 |
managedRuntime |
5 | 2000 |
pollIntervalUs |
T / 2 | T |
score = nominalUs + tailUs / 10, in integer arithmetic (so HIDMaestro's Xbox row is
36 + 515/10 = 36 + 51 = 87). tier bands the score — lowest < 10 ≤ low < 60 ≤
medium < 250 ≤ high — and rank is that tier's ordinal (0–3), mirrored into the
flat latencyRank, so a client that only wants ordering keeps sorting on it exactly as
before. submitPath ∈ kernel-direct | usermode-shm | usermode-broker is the
structural shorthand, for display; it is derived from the same facts and so can never
disagree with the score.
Stability rule. facts describe the implementation and change only when the
implementation changes. nominalUs, tailUs, score, tier, rank and the flat
latency / latencyRank are DERIVED and MAY shift between server versions as the
weights are recalibrated against measurement. A client that needs a stable comparison sorts on rank, or re-derives
from facts with its own weights. It MUST NOT persist the absolute microsecond figures
or present them as measured on this host: they are a model of the code path, not a
benchmark of the machine.
For context when presenting them: a physical USB pad polls at ~1 ms, so every figure
these weights can produce for a signalled (non-polling) backend sits inside a single
poll interval. lifecycle and available are usually the more decision-relevant
fields, and a client that ranks backends by speed alone will be precisely comparing a
quantity the user cannot feel.
Three-layer rule (a field lives in exactly one layer): catalog = what exists & how to present it (static, localized) → capabilities = what is true right now (dynamic) → PUT/GET session responses = what was actually applied per controller.
{
"locale": "en",
"protocolVersion": 1,
"serverVersion": "1.6.0",
"catalogVersion": 2,
"controllerTypes": [
{
"id": 0,
"slug": "xbox360",
"name": "Xbox 360 Controller",
"shortName": "Xbox",
"description": "Best compatibility. Works with virtually every PC game.",
"image": { "href": "/api/catalog/images/xbox360", "etag": "\"1.6.0\"" },
"features": {
"rumble": { "supported": true },
"analogTriggers": { "supported": true },
"motion": { "supported": false },
"lightbar": { "supported": false },
"touchpad": { "supported": false },
"triggerEffects": { "supported": false },
"playerLeds": { "supported": false },
"mic": { "supported": false },
"speaker": { "supported": false }
},
"emulates": { "sdlType": "xbox360", "usb": ["045e:028e"] }
},
{
"id": 1,
"slug": "ds4",
"name": "DualShock 4",
"shortName": "PlayStation",
"description": "PlayStation controller with motion, touchpad and light bar.",
"image": { "href": "/api/catalog/images/ds4", "etag": "\"1.6.0\"" },
"features": {
"rumble": { "supported": true },
"analogTriggers": { "supported": true },
"motion": { "supported": true, "requires": "vigembus>=1.17" },
"lightbar": { "supported": true },
"touchpad": { "supported": true, "modes": ["ds4"] },
"triggerEffects": { "supported": false },
"playerLeds": { "supported": false },
"mic": { "supported": false },
"speaker": { "supported": false }
},
"emulates": { "sdlType": "ps4", "usb": ["054c:05c4"] }
}
],
"hostFeatures": {
"mouseControl": { "supported": true, "modes": ["off", "ds4", "mouse"] },
"keyboardControl": { "supported": false },
"rumble": { "supported": true }
}
}catalogVersionis the catalog SCHEMA version (integer), distinct fromprotocolVersion(the wire protocol) andserverVersion(the build). It increments when the payload shape evolves in a way a client may branch on: v2 offers up to four types per backend and adds per-typeemulates; a response OMITTING the field is the legacy v1 catalog (xbox360 + ds4, no emulates) — a client reads absent as 1. Additive within protocolVersion 1; a client MAY substitute its own known representation for a recognized legacy version.controllerTypes[].idis the wire enum value used as descriptortype(0 xbox360, 1 ds4, 2 dualsense, 3 switchpro). The client renders its "Emulate" picker from this list instead of hardcoding the enum.- The offered set is the union of the host's configured backends — only identities
the receiver can natively materialize. Linux/uinput offers all four; Windows offers
all four (xbox360 + ds4 prefer ViGEm's kernel path, dualsense + switchpro
materialize via HIDMaestro, which also covers xbox360 + ds4 when ViGEmBus is
absent); macOS/IOHIDUserDevice offers ds4 (drops the fake Xbox; DualSense pending
its report codec). The catalog is static per server version (its ETag is keyed on
version + locale), so a type stays offered even while its backend's driver is
missing — the plug then fails per-controller (
backendUnavailablewhen no backend bus opened, elsepluginFailed) andcapabilities.backendsnames which driver to fix. A type the server does not offer at all returns per-controllerinvalidType. requiresis a structured code ("vigembus>=1.17","hidmaestro>=1.7"), not prose. With multiple backends it names the requirement of the type's PREFERRED materializer.controllerTypes[].emulatesis an OPTIONAL physical-pad identity hint —{ "sdlType": …, "usb": [ "vid:pid", … ] }— naming the physical controller this virtual type is the natural default for (e.g.ds4← aps4pad / USB054c:05c4). It lets a client later match a detected physical pad to a default emulation type with NO protocol change: the mapping policy lives on the server, not in each client's switch.sdlTypemirrors the clients'SDL_GameControllerTypevocabulary (xbox360,ps4,ps5,switchpro);usbis lowercasevendor:product, an array so more hardware revisions can be added later. Additive within protocolVersion 1; interim clients IGNORE it and default to the first offered type. Rides only offered types.- Type-feature slugs are protocol constants:
rumble,analogTriggers,motion,lightbar,touchpad,triggerEffects(DualSense adaptive-trigger passthrough, RECEIVE),playerLeds(player-indicator LEDs, RECEIVE),mic(the pad's own microphone endpoint, SEND) andspeaker(the pad's own speaker/headset endpoint, RECEIVE).triggerEffectsandplayerLedsreflect whether the type's preferred materializer surfaces the game's raw output reports;mic/speakerreflect whether it can materialize a pad that carries real audio endpoints (see Controller audio). A client advertises the matching descriptor caps only when it can actuate them on the physical pad. - A type-feature MAY carry an explicit
modesarray of protocol-constant mode slugs so the client reads the offered modes rather than inferring them from the type id. The DS4touchpadadvertises["ds4"](its pad-render mode); the relative-mouse path is host injection and lives underhostFeatures.mouseControl, not the type. A type with a touchpad but nods4mode therefore gates the pad off while keeping host mouse. Absentmodes= a pre-modes catalog; the client falls back to its prior assumption. hostFeaturesis PURE capability data: what the HOST can be driven to do, independent of any controller slot. Inventory:mouseControl(the touchpad relative-mouse path;modesenumerates valid descriptortouchpadModevalues),keyboardControl(host keystroke injection, SEND), andrumble(the host streams feedback back to the client, RECEIVE). A feature the backend cannot do reportssupported: false(e.g.keyboardControluntil an injection backend ships) and the client leaves it unoffered. Future slugs when implemented:mediaKeys, …- Scope: the catalog describes what the SATELLITE can create or do on the host. Which physical pads the client can read stays client-side knowledge.
Localization boundary rule: translate only what the client merely DISPLAYS.
- Localized: controller-type
name/shortName/description(+ images). These are server-owned emulation targets; new types must render on old apps from server-provided content. - NEVER localized (protocol constants): feature slugs (
rumble,motion, …), host-feature slugs, touchpad modes,requirescodes,emulatesvalues (sdlType/usb), denial reasons, apply results. A client can only offer what it has code for and carries its own translations. - Consequently: an unknown feature/hostFeature slug is NOT OFFERED (ignored gracefully, never an error); an unknown controller-TYPE id/slug DOES render, from server-provided name/description/image. Clients never show blank UI for a type newer than the app.
- The client MAY override type slugs it recognizes with bundled art/translations.
Localization mechanics: standard Accept-Language; fallback chain → en; the
response echoes the resolved locale. Locale set is kept in lockstep with dish-android
(en, es, fr, de, bs, pt-BR) with a CI completeness gate.
Caching: ETag = "<serverVersion>+<locale>"; If-None-Match → 304. Content changes
only on server upgrade, so a client fetches once per satellite version per language.
Theme-neutral SVG served by the satellite itself (works fully offline). ETag = "<serverVersion>", If-None-Match → 304. 404 for unknown slugs.
pairingKey(32 bytes): long-lived trust root, minted at pairing, stored both ends. Never used to encrypt traffic.sessionKey(32 bytes): per-session, derived on both ends after each session PUT:
sessionSalt = 8 random bytes, minted by the server per PUT (hex in the response)
sessionKey = HKDF-SHA256( ikm = pairingKey,
salt = sessionSalt,
info = "satellite-session-v1" || token(4 bytes BE) )
HKDF-SHA256 is RFC 5869 extract-then-expand built on HMAC-SHA256 (one 32-byte output
block: T1 = HMAC(PRK, info || 0x01)).
Because the key rotates with the token every PUT, counters restart at 1 with no keystream reuse across sessions (the historical flaw: long-lived key + counter reset).
cleartext header : token(4 BE) | counter(4 BE)
ciphertext : ChaCha20-Poly1305-IETF( sessionKey, nonce, AAD = token(4 BE) )
nonce (12 bytes) : dir(1) | 0×7 | counter(4 BE)
dir = 0x00 client → server, 0x01 server → client
inner plaintext : msgType(2 BE) | msgLen(2 BE) | payload
- Each direction keeps its own monotonically increasing counter, starting at 1. The direction byte ensures the two directions never share a nonce even under one key.
- A datagram is at most 1500 bytes, both directions: one Ethernet MTU, so a full
packet crosses a LAN without fragmenting (a fragmented audio frame would be an
all-or-nothing loss anyway). Overhead is header(8) + tag(16), leaving 1476 bytes for
msgType | msgLen | payloadand so 1472 bytes of payload. Everything but audio sits under 30 bytes; a 20 ms Opus packet is ~80 bytes (mic) to ~240 bytes (speaker), so the ceiling exists to absorb a VBR spike rather than to be approached. Senders MUST stay inside it; a larger datagram is truncated on read and fails the AEAD. - Replay guard (receiver side, per direction): drop when
counter <= lastCounter(first packet exempt whilelastCounter == 0). - Counter exhaustion is impossible to wrap: a counter can never go backwards, so a session that exhausts the 2^32 space goes silent and self-heals via re-PUT (fresh token, salt, key). Clients SHOULD proactively re-PUT when their send counter crosses 0xF0000000.
Up (client → server):
| Opcode | Name | Payload |
|---|---|---|
| 0x0001 | INPUT | ctrlIdx(1) + GamepadReport(12, XUSB layout, LE) |
| 0x0002 | HEARTBEAT | empty |
| 0x000A | MOTION | ctrlIdx(1) + gyroX/Y/Z(3×i16 LE) + accelX/Y/Z(3×i16 LE) + timestampDeltaUs(u32 LE) |
| 0x000B | BATTERY | ctrlIdx(1) + level(1: 0..100 or 0xFF unknown) + status(1: 0 unknown,1 discharging,2 charging,3 full,4 wired) |
| 0x000C | TOUCHPAD (v1, 16B) | ctrlIdx(1) + flags(1: b0 f0 active, b1 f1 active, b2 button) + f0 id(1)+x(i16 LE)+y(i16 LE) + f1 id(1)+x(i16 LE)+y(i16 LE) + eventTimeMs(u32 LE) |
| 0x000C | POINTER (v2, 19B) | ctrlIdx(1) + fingerFlags(1: b0 f0 active, b1 f1 active) + buttons(1: b0 left/click, b1 right, b2 middle) + f0 id(1)+x(i16 LE)+y(i16 LE) + f1 id(1)+x(i16 LE)+y(i16 LE) + eventTimeMs(u32 LE) + scrollV(i16 LE, 120 per wheel notch, an event: resends carry 0) |
| 0x0012 | MIC_AUDIO | ctrlIdx(1) + seq(u16 BE) + opusPacket(rest, >= 1 byte): one 20 ms Opus packet from the pad's microphone, mono 48 kHz. Accepted only from senders whose descriptor advertised mic. See Controller audio. |
Down (server → client):
| Opcode | Name | Payload |
|---|---|---|
| 0x0003 | HEARTBEAT_ACK | backendAvailable(1) + totalActiveControllers(1) + epoch(u16 BE) + activeBitmap(u16 BE) |
| 0x0009 | RUMBLE | ctrlIdx(1) + strong(u16 BE) + weak(u16 BE) + durationMs(u16 BE) |
| 0x000D | LIGHTBAR | ctrlIdx(1) + r(1) + g(1) + b(1) |
| 0x000F | SESSION_CLOSE | reason(1: 0 shutdown, 1 kicked, 2 replaced, 3 unpaired) |
| 0x0010 | TRIGGER_EFFECTS | ctrlIdx(1) + left block(11) + right block(11): raw DualSense trigger-effect fields (mode byte + 10 params each), forwarded verbatim from the game's output report. Sent only to senders whose descriptor advertised triggerEffects. Coalesced; both blocks always ride together (the server merges per-trigger writes). |
| 0x0011 | PLAYER_LEDS | ctrlIdx(1) + ledMask(1): player-indicator bitmask, bit 0 = leftmost LED (DualSense bits 0-4, Switch Pro bits 0-3). Sent only to senders whose descriptor advertised playerLeds. |
| 0x0013 | SPEAKER_AUDIO | ctrlIdx(1) + seq(u16 BE) + opusPacket(rest, >= 1 byte): one 20 ms Opus packet for the pad's speaker/headset output, stereo 48 kHz. Sent only to senders whose descriptor advertised speaker. See Controller audio. |
| 0x0014 | MIC_LED | ctrlIdx(1) + state(1: 0 off, 1 on, 2 pulse): the mic-mute lamp state the game asked for. Coalesced last-value-wins like LIGHTBAR. Sent only to senders whose descriptor advertised mic (a mute lamp with no microphone behind it has nothing to report). |
Opcodes 0x0004 (ADD), 0x0005 (REMOVE), 0x0006 (ACK), 0x0007 (SERVER_STATUS) and 0x0008 (TYPE), 0x000E (CAPS_UPDATE) are deleted: topology mutation is REST-only, and server status rides in every heartbeat ack. Deleted opcodes are never reused; new messages claim fresh ids (0x0010+).
Corrected stream semantics (errors in the former docs, preserved here):
- INPUT frames are full-state snapshots per packet, loss-safe by construction. Never delta-encode them.
- Heartbeat cadence is 2000 ms (not 250 ms), dead at 5 misses, "not responding" display state at 2 misses. Heartbeats stay UDP: their job is proving the DATA path works; REST can't.
- RUMBLE
durationMsis stamped by the server (500 ms when the host API has no duration; refresh arrives before expiry). Stop = magnitudes 0,0. - MOTION scale: gyro ±2000 deg/s, accel ±4 g over int16; right-handed frame, +X right, +Y up, +Z toward player. Senders apply the rotation; receivers do not.
- Telemetry streams stay lossy/incremental by design; do NOT add reliability layers.
Every 0x0003 carries the session epoch (u16, wraps) and a 16-bit bitmap of active
controller indices. The client compares both against its last-known applied state
(from the latest PUT/GET response): on mismatch → GET /api/connections/{id} →
re-PUT desired state. Involuntary server-side losses (replug failure, bus death, reap
of a sibling, kick racing a packet) are therefore SELF-HEALING within one heartbeat
(≤2 s) without REST polling. epoch increments on every applied-topology change
regardless of initiator.
Sent encrypted with the session key BEFORE teardown on: server shutdown (broadcast,
reason shutdown), admin kick (kicked), session replacement by a new PUT
(replaced, to the OLD token), and unpair of a device with a live session
(unpaired). Best-effort ONLY: the reliable truth is ack silence → heartbeat death →
REST PUT → status code (401 unpaired / 503 shutting down).
Security rule for server → client control signals: they exist ONLY while the session (and its key) exists, because they must be authenticated: an unauthenticated "you're unpaired" datagram would be a trivially spoofable DoS. After teardown, silence plus REST reason codes are the only safe channel; "unpaired" therefore CANNOT be pushed to an un-keyed client and surfaces as 401 on the next REST contact.
Scope first, because the name invites the wrong reading: this carries the EMULATED
PAD's OWN audio endpoints, never the host's game audio. A DualSense (or DualShock 4
v2) materialized through a composite persona presents real Windows endpoints, named
after that persona's USB product string — the DualSense composite is
Speakers (DualSense Wireless Controller) out and Headset Microphone (DualSense Wireless Controller) in; the DualShock 4 v2 composite carries the plain "Wireless
Controller" string, and its out terminal is a headset rather than a speaker, so
Windows may not label it "Speakers" at all. Whatever a game writes to that speaker
endpoint rides 0x0013 to the client; whatever the client's microphone captures rides
0x0012 back into that mic endpoint. General audio streaming is out of scope and
always will be.
These three messages EXTEND protocol 2 IN PLACE. Version 2 has never been released, so
there is no deployed client to migrate and no version bump: protocolVersion stays 2.
Every addition is caps-gated, so an interim protocol-2 client that advertises neither
audio cap sees exactly the traffic it saw before.
| Property | MIC_AUDIO 0x0012 (up) | SPEAKER_AUDIO 0x0013 (down) |
|---|---|---|
| Payload | ctrlIdx(1) + seq(u16 BE) + one Opus packet | same |
| Rate | 48 kHz | 48 kHz |
| Frame | 20 ms = 960 samples per channel | 20 ms = 960 samples per channel |
| Channels | mono | stereo |
| Opus application | VOIP | AUDIO |
| Bitrate | ~32 kbps VBR, in-band FEC on | ~96 kbps VBR, in-band FEC on |
| Silence | Opus DTX on (encoder-side VAD) | all-zero windows not sent, seq not advanced |
| Cap gate | mic |
speaker |
- The format is FIXED on the wire, never negotiated. Opus resamples to 48 kHz internally regardless, so pinning the rate costs nothing and spares both ends a resampler; 20 ms is also the pad's USB-audio service interval, so no side re-windows.
- One message carries exactly one Opus packet. Opus packets are self-delimiting, so the frame length IS the packet length; a frame with the 3-byte header and no Opus byte is malformed (a silence frame is a 1-byte DTX packet, not an empty one, and the mic encoder really does emit those — see the silence bullet below).
seqis u16 and WRAPS. It exists only for gap detection and for late-drop inside the receiver's 2-frame (40 ms) reorder window. There are NO acks and NO retransmits: the lossy-telemetry rule above applies, and Opus conceals loss itself with in-band FEC (the next packet re-carries a coarse copy of the previous one) plus PLC on a gap. Adding a reliability layer here would trade a 20 ms hole for unbounded latency.- Silence is handled differently in each direction, on purpose. The mic encoder runs Opus DTX: a live microphone never goes digitally silent, so a VAD is the only thing that can collapse a quiet room (measured on libopus 1.6.1: digital silence 8.4 → 1.1 kbps; -50 dBFS room noise after speech gated 123 of 250 frames, 30.0 → 16.4 kbps). The speaker encoder declines DTX — that gate cuts anything ~26-30 dB below the recent peak, which on game audio replaces a reverb tail or quiet ambience with comfort noise at -2.3 dB SNR — and the server instead drops any 20 ms window that is EXACTLY all-zero, which is what Windows renders into the endpoint whenever nothing is playing to it. That saves ~28 kbps of Opus and ~52 kbps on the wire, once the 59 bytes of per-packet framing are counted (IPv4 20 + UDP 8 + token/counter 8 + Poly1305 16 + inner header 4 + audio header 3).
- A suppressed speaker window does NOT advance
seq, and a receiver MUST NOT read the resulting cadence as loss. A suppressed window is not a hole: nothing was lost, and concealing it would have Opus invent noise where the game wrote none. The opposite case, a window whose encode failed, DOES advanceseq— that 20 ms really happened, so the client conceals it rather than playing the stream short and drifting against the game's clock. - Speaker content is channels 1/2 of the DualSense 4-channel OUT stream (its speaker and headset jack). Channels 3/4 are the HD-haptics lanes and deliberately never cross the wire. DualShock 4 v2 headset stereo rides as-is.
- Server-side sanity limit: MIC_AUDIO beyond ~75 packets/s per controller is dropped (nominal is 50). Audio for an unknown session, an unbound slot, or a slot that never advertised the cap is likewise dropped, silently on the wire, with one server log line per session per cause.
Mic-mute button. GamepadReport.wButtons is XINPUT-shaped and has exactly one free
bit left, 0x0800, which is now the DualSense mic-mute button. Only the DualSense
identity maps it into the emulated pad's input report; every other identity ignores it
(no other emulated pad has a mute button). The bit is state, not an edge, like every
other button in the word.
Mute is the client's to enforce (privacy invariant). Muted means ZERO MIC_AUDIO
packets on the wire, not silent ones: the client stops delivering capture, and it also
sets wButtons bit 0x0800 so host-side software can see the state. A host cannot mute
a client's microphone, and a client that is muted cannot be talked into streaming.
MIC_LED 0x0014 is advisory in the other direction: it reports which lamp state the game
asked for, and the client renders it (last writer wins against its own local mute
state). Because the LED describes a microphone, it rides mic, not a cap of its own.
Caps and catalog. mic and speaker follow the house rule that a cap advertises
the CLIENT's actuator/source: the client claims a microphone it can capture from and a
speaker it can play to, and the server sends or accepts accordingly. They are
independent directions; one does not imply the other. The catalog advertises the
type-feature slugs mic/speaker only for the types whose preferred materializer can
build a pad with audio endpoints (the two Sony types via HIDMaestro's composite
personas); every other type and backend reports false. The host's own per-direction
switches are a third, separate thing and live in /api/server/capabilities's
controllerAudio block: a cap says what the CLIENT can do, the catalog says what the
host COULD build, and that block says what the host will actually carry right now.
MIC_AUDIO 0x0012 is the first inhabitant of this slot: a stream that flows INTO the host from the client, beyond the input reports themselves. It follows the rules below, with the per-controller cap standing in for a hostFeature grant (audio belongs to one emulated pad, not to the host).
When host features ship beyond touchpad-mouse (0x000C), their streams get new opcodes and follow the loss-safety rule: state that must not stick goes FULL-STATE per frame (keyboard = the complete pressed-key set every packet); deltas only where loss is benign (mouse moves, audio frames). Streams are valid only for session-granted hostFeatures; the server drops streams for ungranted features.
- A fresh/rotated session has a REST-open grace window of 15 s: the PUT counts as provisional liveness so a half-open session (UDP blocked) doesn't flap while the client notices and surfaces the failure.
- After the grace window: a session is "not responding" after 4 s without a decrypted packet (2 heartbeat intervals) and reaped after 10 s (5 intervals). Reaping unplugs the session's pads (epoch/bitmap of other sessions unaffected).
- HTTPS requests do NOT refresh UDP liveness (they prove the wrong path).
| Route | Purpose |
|---|---|
GET /api/devices |
Paired devices with link state (paired / active / notResponding) |
DELETE /api/devices/{deviceId} |
Unpair; closes any live session (notify unpaired) |
GET /api/connections |
Live sessions + per-controller truth (pluggedIn reflects the adapter, not serialNo > 0) |
DELETE /api/connections/{connectionId} |
Kick (notify kicked); transient, client may reconnect |
GET /api/backend/status |
Preferred backend plus the per-host backends array (identity, availability, driver/bundled version, lifecycle) |
GET /api/debug |
Wire, audio and host telemetry for the diagnostics page |
Counters are cumulative since the receiver bound its socket (once per process in
practice) and are not part of the client contract: this is an operator surface and
fields may be added at any time. Alongside the long-standing scalars (listening,
packets, submitOk, submitFail, lastLoopUs, senderIP, udpPort, decryptFail,
replayDrop, backendAvailable, backend) it carries:
| Field | Meaning |
|---|---|
rx |
Accepted inbound messages by type (input = submitOk + submitFail, heartbeat, motion, battery, pointer, micAudio) and the rejections that never reached a decoder (malformed = known opcode whose length guard failed, unknownType, runt = datagram under 28 bytes, unknownToken) |
tx |
Outbound datagrams (packets, bytes) and their split by message (heartbeatAck, rumble, lightbar, triggerEffects, playerLeds, speakerAudio, micLed, sessionClose), plus the four send failures (unroutable, encryptFailed, oversize, sendFailed) |
audio |
Controller-audio stream health: micAccepted / micLate / micDropped partition every inbound frame, micDecoded + micFecRecovered + micConcealed count what reached the pad, and speakerSent / speakerSilenceSuppressed / speakerEncodeFailed / speakerLockContended the outbound side |
auth |
Client-API 401s, split notPaired / badProof |
peakLoopUs |
True hot-path peak. maxLoopUs keeps its read-and-zero window semantics for benchmark tooling and reads 0 unless a new all-time record was set since the last read |
webPort, mdnsResponderActive, clientApiListening, connections, controllers, maxControllers, sessionsReaped |
Host gauges |
The admin surface never sets descriptor fields (single-writer rule). The former
POST /api/devices/touchpad-mode (both surfaces) and POST /api/devices/remove are
deleted; connectedAtEpoch in connection JSON is steady-clock seconds (boot-
relative), not Unix epoch.
protocolVersion (integer, currently 2) rides in every pairing/session request and
response. The server ACCEPTS the whole [supportedMin, supported] range (currently
[1, 2]), settles each session on the client's offer, and echoes that settled
version in the pairing/session responses; absent means 1 (a pre-versioning client).
The settled version keys the session's wire frames: a v1 session streams the 16-byte
TOUCHPAD frame, a v2 session the 19-byte POINTER frame (the receiver also disambiguates
by length, so mixed generations coexist).
An offer OUTSIDE the range is rejected with 409
{"error":"protocol version unsupported","supported":2,"supportedMin":1}. supported
is the newest version the server speaks: a client seeing supported above its own
current version must tell the user to update the app; below its own minimum, to update
the satellite. Version negotiation happens ONLY on pairing and the session PUT;
sub-resource writes (/controllers/{idx}) inherit their session's settled version and
carry no protocolVersion field.
An in-range but older session works fully at its own version; both ends surface a soft "update for the newest features" hint (the app's satellite chip, the dashboard's Update Dish chip + a server log line), never an error.
Version history:
- 1: initial contract.
- 2: 0x000C reshaped into the POINTER frame: the click moved out of the finger
flags into a buttons byte (left/right/middle) and a signed vertical wheel was added.
Added the controller-feedback return paths: TRIGGER_EFFECTS 0x0010 and PLAYER_LEDS
0x0011 with their descriptor caps (
triggerEffects,playerLeds) and catalog type-feature slugs. Both are caps-gated, so a client that never advertises them never sees them; no frame shape changed. Then controller audio: MIC_AUDIO 0x0012, SPEAKER_AUDIO 0x0013 and MIC_LED 0x0014 with the capsmic/speaker, the matching catalog slugs, andwButtonsbit 0x0800 as the DualSense mic-mute button. The datagram ceiling rose to 1500 bytes to fit an Opus packet. Version 2 was never released, so all of this EXTENDS 2 IN PLACE rather than claiming a 3: there is no deployed client to migrate, and every addition is caps-gated or additive, so an interim protocol-2 client that advertises neither audio cap sees exactly the traffic it saw before. Catalog/capabilities documents advertise the server's newest version so clients can offer it directly.
- 400 malformed request (missing/invalid fields).
- 401
{"error":"unauthorized","code":"NOT_PAIRED"|"BAD_PROOF"}: terminal; re-pair. - 404 unknown resource (or a session that isn't yours).
- 409 protocol version mismatch.
- 503 shutting down; retry later.
- Per-controller failures are 200s with per-controller result codes (partial success is normal, not an error).