From 4d148ebaf7ae44529ba9a3adbbf295fe1b8a9e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Sun, 18 Oct 2026 19:36:45 +0000 Subject: [PATCH] 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. --- packages/ts-types/src/schemas/app.ts | 98 +++++++++++++++++++ packages/ts-types/src/schemas/index.ts | 5 + .../v1/app/telemetry_message.schema.json | 74 ++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 packages/ts-types/src/schemas/app.ts create mode 100644 schemas/continuum/v1/app/telemetry_message.schema.json diff --git a/packages/ts-types/src/schemas/app.ts b/packages/ts-types/src/schemas/app.ts new file mode 100644 index 0000000..edf3a7b --- /dev/null +++ b/packages/ts-types/src/schemas/app.ts @@ -0,0 +1,98 @@ +import { Type, type Static, type TSchema } from "@sinclair/typebox"; + +/** Present but possibly null - a column that exists and has no value yet. */ +const Nullable = (schema: T) => Type.Union([schema, Type.Null()]); + +/** + * What continuum-backend pushes down `/ws/telemetry` to an operator console, + * mirroring `schemas/continuum/v1/app/telemetry_message.schema.json` by hand + * - the JSON Schema is canonical, change it first. + * + * ## Why this is camelCase when everything else here is snake_case + * + * Because this one is not shared with the Rust gateway. `continuum/v1/edge/` + * is snake_case so serde derives those names directly; this is the backend's + * own HTTP vocabulary, and the browser already consumes camelCase from + * `GET /printers`. Giving it a second model of a printer to reconcile would + * be the cost of a consistency nobody benefits from. + * + * This file exists because a previous frontend invented a shape against + * nothing - `printerId`, `nozzleTempC`, `progressPct`, `updatedAt` and a + * closed `idle|printing|paused|error|offline` enum, inside an envelope no + * backend ever sent. Both ends now mirror something. + */ + +/** + * A printer as the console displays it: the same shape `GET /printers/:id` + * returns, so a client can apply a push to the row it already holds. + * + * Every telemetry field is nullable, and that is not laziness - a printer + * that has never reported has no temperature, and "no data yet" is a + * different thing from 0 degrees. A client that renders `?? 0` here will + * show a cold printer as a real reading. + */ +export const AppPrinterStateSchema = Type.Object({ + id: Type.String({ format: "uuid" }), + farmId: Type.String({ format: "uuid" }), + name: Type.String(), + vendor: Type.String(), + status: Type.Optional(Nullable(Type.String())), + /** + * The printer's own word for what it is doing - a Bambu's `RUNNING`, + * `PREPARE`, `FINISH`. Deliberately not an enum: an unrecognised state + * must reach the screen as itself rather than being dropped on the way. + */ + vendorState: Type.Optional(Nullable(Type.String())), + vendorErrorCode: Type.Optional(Nullable(Type.Integer())), + nozzleTempC: Type.Optional(Nullable(Type.Number())), + nozzleTargetTempC: Type.Optional(Nullable(Type.Number())), + bedTempC: Type.Optional(Nullable(Type.Number())), + bedTargetTempC: Type.Optional(Nullable(Type.Number())), + progressPercent: Type.Optional(Nullable(Type.Integer())), + currentLayer: Type.Optional(Nullable(Type.Integer())), + totalLayers: Type.Optional(Nullable(Type.Integer())), + remainingMinutes: Type.Optional(Nullable(Type.Integer())), + currentJobName: Type.Optional(Nullable(Type.String())), + /** A count of active HMS entries, not messages - we lack Bambu's tables. */ + activeAlerts: Type.Optional(Nullable(Type.Integer())), + /** + * 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 `GET /printers/:id/stream` is worth + * calling; null means render no player at all. + */ + cameraStream: Type.Optional(Nullable(Type.String())), + /** + * 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 on its own cannot say. + */ + lastSeenAt: Type.Optional(Nullable(Type.String())), +}); +export type AppPrinterState = Static; + +export const AppTelemetryMessageSchema = Type.Union([ + Type.Object( + { + type: Type.Literal("printer.telemetry"), + printer: AppPrinterStateSchema, + }, + { additionalProperties: false }, + ), + /** + * Sent once, right after the socket authenticates. + * + * Worth a message of its own rather than letting the client infer + * readiness from silence: an operator with no printers claimed and an + * operator whose gateway is offline both see nothing arrive, and those two + * need different things said to them. + */ + Type.Object( + { + type: Type.Literal("ready"), + visiblePrinters: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, + ), +]); +export type AppTelemetryMessage = Static; diff --git a/packages/ts-types/src/schemas/index.ts b/packages/ts-types/src/schemas/index.ts index 481c3ff..f8f155e 100644 --- a/packages/ts-types/src/schemas/index.ts +++ b/packages/ts-types/src/schemas/index.ts @@ -14,3 +14,8 @@ export * from "./edge"; // stream:camera_frames work queue and the channel:print_alerts notification // channel. Mirrors schemas/continuum/v1/ai/. export * from "./ai"; + +// The continuum-backend -> operator console push protocol: /ws/telemetry. +// Mirrors schemas/continuum/v1/app/. camelCase, unlike the rest - see the +// file for why. +export * from "./app"; diff --git a/schemas/continuum/v1/app/telemetry_message.schema.json b/schemas/continuum/v1/app/telemetry_message.schema.json new file mode 100644 index 0000000..758edbc --- /dev/null +++ b/schemas/continuum/v1/app/telemetry_message.schema.json @@ -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"] + } + } +}