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"] + } + } +}