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
|
||||
// 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";
|
||||
|
||||
Reference in New Issue
Block a user