Add the operator console's telemetry push contract

`/ws/telemetry` is the socket a live fleet view needs and the backend does
not have yet. This is the shape both ends will mirror, written first so
neither invents one.

Which is exactly what happened before: continuum-app's telemetry store
consumes `printerId`, `nozzleTempC`, `bedTempC`, `progressPct`,
`updatedAt` and a closed `idle|printing|paused|error|offline` enum, inside
an envelope `{type: "printer.telemetry", data}` that no backend ever sent.
Every field of it was invented against nothing.

Two deliberate departures from the rest of this repository:

- **camelCase**, where `continuum/v1/edge/` is snake_case. That one is
  shared with a Rust gateway whose serde derives the names directly; this
  one is the backend's own HTTP vocabulary, and the browser already
  consumes camelCase from `GET /printers`. Making it snake_case would buy
  a consistency nobody benefits from at the price of a second model of a
  printer in the client.
- **The payload is the printer resource**, the same shape
  `GET /printers/:id` returns, rather than a telemetry-shaped subset. A
  client can then apply a push to the row it already holds instead of
  reconciling two vocabularies for the same machine.

Every telemetry field is nullable, which is load-bearing rather than
lazy: a printer that has never reported has no temperature, and "no data
yet" is not 0 degrees. A client rendering `?? 0` will draw a cold
printer as a real reading. `vendorState` is a free string and not an
enum for the same class of reason - an unrecognised state must reach the
screen as itself rather than being dropped on the way.

There is a `ready` message, sent once after the socket authenticates,
carrying how many printers the user can see. It exists because an
operator with nothing claimed and an operator whose gateway is offline
both see nothing arrive, and those two need different things said to
them - which is impossible if readiness is inferred from silence.

No Rust mirror: this protocol has no Rust consumer. `npm run typecheck`
and `npm run build` clean.
This commit is contained in:
2026-10-18 19:36:45 +00:00
parent 9fd3a628f8
commit 4d148ebaf7
3 changed files with 177 additions and 0 deletions
@@ -0,0 +1,74 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/app/telemetry_message.schema.json",
"title": "AppTelemetryMessage",
"description": "What continuum-backend pushes down /ws/telemetry to an operator console. Discriminated on 'type'. NOTE the casing: camelCase, unlike continuum/v1/edge/ and every other schema here, because this is the backend's own HTTP vocabulary rather than a format shared with the Rust gateway - the browser already consumes camelCase from GET /printers and should not have to hold two models of a printer. A previous frontend invented its own shape against nothing (printerId, nozzleTempC, progressPct, a closed state enum) and no backend ever sent it; this file exists so both ends have something to mirror.",
"oneOf": [
{
"type": "object",
"title": "PrinterTelemetryPush",
"description": "One printer's current state, sent whenever its gateway reports a change. The payload is deliberately the same shape GET /printers/:id returns, so a client can apply it to the row it already has rather than reconciling two vocabularies. Only printers in an organisation the authenticated user belongs to are ever sent.",
"properties": {
"type": { "const": "printer.telemetry" },
"printer": { "$ref": "#/$defs/PrinterState" }
},
"required": ["type", "printer"],
"additionalProperties": false
},
{
"type": "object",
"title": "Ready",
"description": "Sent once, immediately after the socket authenticates, naming how many printers the user can see. Worth its own message rather than leaving the client to infer readiness from silence: an operator with no printers claimed and an operator whose gateway is offline both see nothing arrive, and those need different things said to them.",
"properties": {
"type": { "const": "ready" },
"visiblePrinters": { "type": "integer", "minimum": 0 }
},
"required": ["type", "visiblePrinters"],
"additionalProperties": false
}
],
"$defs": {
"PrinterState": {
"type": "object",
"description": "A printer as the console displays it. Every telemetry field is nullable because a printer that has never reported has no temperature, and 'no data yet' is not 0 degrees.",
"properties": {
"id": { "type": "string", "format": "uuid" },
"farmId": { "type": "string", "format": "uuid" },
"name": { "type": "string" },
"vendor": { "type": "string" },
"status": {
"type": ["string", "null"],
"description": "The backend's own narrowed status, which the UI filters and groups on."
},
"vendorState": {
"type": ["string", "null"],
"description": "The printer's own word for what it is doing - a Bambu's 'RUNNING', 'PREPARE', 'FINISH'. Kept alongside 'status' rather than replaced by it, because the narrowed enum necessarily loses detail an operator sometimes needs. Not an enum here on purpose: an unrecognised state must reach the screen as itself rather than being dropped."
},
"vendorErrorCode": { "type": ["integer", "null"] },
"nozzleTempC": { "type": ["number", "null"] },
"nozzleTargetTempC": { "type": ["number", "null"] },
"bedTempC": { "type": ["number", "null"] },
"bedTargetTempC": { "type": ["number", "null"] },
"progressPercent": { "type": ["integer", "null"] },
"currentLayer": { "type": ["integer", "null"] },
"totalLayers": { "type": ["integer", "null"] },
"remainingMinutes": { "type": ["integer", "null"] },
"currentJobName": { "type": ["string", "null"] },
"activeAlerts": {
"type": ["integer", "null"],
"description": "How many Health-Management-System entries the printer currently has active. A count, not messages: decoding them needs Bambu's HMS tables, which Continuum does not have."
},
"cameraStream": {
"type": ["string", "null"],
"description": "The go2rtc stream name, when this printer has a camera that can actually be restreamed. Null is the common case - only Bambu's X1/H2 class publishes an RTSP URL. Present means it is worth calling GET /printers/:id/stream; null means render no player."
},
"lastSeenAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "When a gateway last reported this printer. Null means never. This is how a client tells 'idle' from 'nobody has heard from it in an hour', which the status alone cannot say."
}
},
"required": ["id", "farmId", "name", "vendor"]
}
}
}