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:
@@ -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 = <T extends TSchema>(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<typeof AppPrinterStateSchema>;
|
||||||
|
|
||||||
|
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<typeof AppTelemetryMessageSchema>;
|
||||||
@@ -14,3 +14,8 @@ export * from "./edge";
|
|||||||
// stream:camera_frames work queue and the channel:print_alerts notification
|
// stream:camera_frames work queue and the channel:print_alerts notification
|
||||||
// channel. Mirrors schemas/continuum/v1/ai/.
|
// channel. Mirrors schemas/continuum/v1/ai/.
|
||||||
export * from "./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";
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user