Compare commits
10
Commits
4dd077a002
...
71f8ea94b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71f8ea94b7
|
||
|
|
ab9274497e
|
||
|
|
14cf7feb88
|
||
|
|
43242c0410
|
||
|
|
9b8f0c2308
|
||
|
|
a8552047c3
|
||
|
|
9b57c22fff
|
||
|
|
082a0cc53d
|
||
|
|
9b348902a7
|
||
|
|
03da12ad4e
|
@@ -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>;
|
||||||
@@ -54,6 +54,17 @@ export const PrinterTelemetrySchema = Type.Object({
|
|||||||
* stage looks stuck.
|
* stage looks stuck.
|
||||||
*/
|
*/
|
||||||
stage: Maybe(Type.Integer()),
|
stage: Maybe(Type.Integer()),
|
||||||
|
/**
|
||||||
|
* The stream name to ask the gateway's `stream_base_url` for, when this
|
||||||
|
* printer has a camera the gateway can restream. Absent means no stream.
|
||||||
|
*
|
||||||
|
* Absent is the common answer today: a Bambu P1/A1 publishes no RTSP URL
|
||||||
|
* at all - a real P1P's ipcam block has `ipcam_dev` and `ipcam_record` and
|
||||||
|
* nothing to connect to - so only the X1/H2 class works this way. The rest
|
||||||
|
* need Bambu's proprietary port-6000 JPEG stream, which no gateway
|
||||||
|
* implements yet.
|
||||||
|
*/
|
||||||
|
camera_stream: Maybe(Type.String()),
|
||||||
layer: Maybe(Type.Integer({ minimum: 0 })),
|
layer: Maybe(Type.Integer({ minimum: 0 })),
|
||||||
total_layers: Maybe(Type.Integer({ minimum: 0 })),
|
total_layers: Maybe(Type.Integer({ minimum: 0 })),
|
||||||
remaining_minutes: Maybe(Type.Integer({ minimum: 0 })),
|
remaining_minutes: Maybe(Type.Integer({ minimum: 0 })),
|
||||||
@@ -74,7 +85,48 @@ export type PrinterTelemetry = Static<typeof PrinterTelemetrySchema>;
|
|||||||
* JSON Schema for why that variant is absent until the gateway's
|
* JSON Schema for why that variant is absent until the gateway's
|
||||||
* start-an-uploaded-file path has been run against real hardware by hand.
|
* start-an-uploaded-file path has been run against real hardware by hand.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Every variant is closed (`additionalProperties: false`), unlike
|
||||||
|
* `PrinterTelemetrySchema` which is deliberately open. The asymmetry is the
|
||||||
|
* point, and it follows from who writes each message.
|
||||||
|
*
|
||||||
|
* Telemetry is written by gateways in the field, so it is read liberally: an
|
||||||
|
* unknown field must not cost a whole farm's batch, and a gateway that is
|
||||||
|
* newer than the backend should still be understood.
|
||||||
|
*
|
||||||
|
* Commands are written by this backend, are a handful of fields, and move
|
||||||
|
* physical machines. Here an unrecognised field is a bug on the sending side,
|
||||||
|
* and silently dropping it is the worst available outcome - a caller that
|
||||||
|
* misspells a field would watch its command succeed and do something other
|
||||||
|
* than what it asked for. `ams_mapping` is the concrete case: it is
|
||||||
|
* deliberately not part of `start_print`, and a caller that passes one must
|
||||||
|
* be told, not ignored.
|
||||||
|
*/
|
||||||
export const PrinterCommandSchema = Type.Union([
|
export const PrinterCommandSchema = Type.Union([
|
||||||
|
Type.Object({
|
||||||
|
action: Type.Literal("start_print"),
|
||||||
|
/**
|
||||||
|
* The cloud's `print_jobs.id`. The gateway does not need it to do the
|
||||||
|
* work - it is here so a gateway's logs line up with a job.
|
||||||
|
*/
|
||||||
|
job_id: Type.String({ format: "uuid" }),
|
||||||
|
/**
|
||||||
|
* What to call the file on the printer's own storage. A `.3mf` is a
|
||||||
|
* sliced project and its filament mapping is read out of the archive;
|
||||||
|
* anything else is bare gcode and carries no mapping.
|
||||||
|
*/
|
||||||
|
file_name: Type.String({ minLength: 1, maxLength: 500 }),
|
||||||
|
/**
|
||||||
|
* A presigned, expiring GET for the job file - a URL rather than the
|
||||||
|
* bytes, because the file is tens of megabytes and this is a JSON
|
||||||
|
* control channel, and because it keeps object-store credentials out of
|
||||||
|
* every gateway on every farm.
|
||||||
|
*
|
||||||
|
* Presign this at *dispatch* time, never at job creation: a queued job
|
||||||
|
* may wait days and the URL would be long expired.
|
||||||
|
*/
|
||||||
|
url: Type.String({ format: "uri" }),
|
||||||
|
}, { additionalProperties: false }),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
action: Type.Literal("set_fan_speed"),
|
action: Type.Literal("set_fan_speed"),
|
||||||
/**
|
/**
|
||||||
@@ -83,7 +135,7 @@ export const PrinterCommandSchema = Type.Union([
|
|||||||
* is set here does not read back identically.
|
* is set here does not read back identically.
|
||||||
*/
|
*/
|
||||||
percent: Type.Integer({ minimum: 0, maximum: 100 }),
|
percent: Type.Integer({ minimum: 0, maximum: 100 }),
|
||||||
}),
|
}, { additionalProperties: false }),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
action: Type.Literal("control_print"),
|
action: Type.Literal("control_print"),
|
||||||
/**
|
/**
|
||||||
@@ -100,21 +152,111 @@ export const PrinterCommandSchema = Type.Union([
|
|||||||
Type.Literal("resume"),
|
Type.Literal("resume"),
|
||||||
Type.Literal("stop"),
|
Type.Literal("stop"),
|
||||||
]),
|
]),
|
||||||
}),
|
}, { additionalProperties: false }),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
action: Type.Literal("send_gcode_line"),
|
action: Type.Literal("send_gcode_line"),
|
||||||
/** The most powerful variant by far; be careful about exposing it to a UI. */
|
/** The most powerful variant by far; be careful about exposing it to a UI. */
|
||||||
line: Type.String(),
|
line: Type.String(),
|
||||||
}),
|
}, { additionalProperties: false }),
|
||||||
]);
|
]);
|
||||||
export type PrinterCommand = Static<typeof PrinterCommandSchema>;
|
export type PrinterCommand = Static<typeof PrinterCommandSchema>;
|
||||||
|
|
||||||
|
const DiscoveredVendor = Type.Union([
|
||||||
|
Type.Literal("bambu"),
|
||||||
|
Type.Literal("klipper"),
|
||||||
|
Type.Literal("prusa_link"),
|
||||||
|
Type.Literal("octoprint"),
|
||||||
|
Type.Literal("prusa_serial"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A printer a gateway found but cannot drive, because it is not in that
|
||||||
|
* gateway's fleet file. Carries no credential and cannot: a Bambu access
|
||||||
|
* code is on the printer's screen and a PrusaLink API key is in its web UI,
|
||||||
|
* and no probe learns either. What it buys is that an operator adding a
|
||||||
|
* printer in the web app does not have to go and read an IP address or a
|
||||||
|
* serial number off a machine.
|
||||||
|
*
|
||||||
|
* Every string here was chosen by a device on the customer's network -
|
||||||
|
* anything that answers an SSDP query or an HTTP probe names itself - so
|
||||||
|
* these are untrusted display strings. Hence the length caps, which are the
|
||||||
|
* reason this one object is closed while its sibling variants are open: an
|
||||||
|
* unbounded `name` from the LAN should not reach a database column or a
|
||||||
|
* React tree, and a gateway has no legitimate reason to send extra fields
|
||||||
|
* here that this backend would have to guess the meaning of.
|
||||||
|
*/
|
||||||
|
export const DiscoveredPrinterSchema = Type.Object({
|
||||||
|
/**
|
||||||
|
* The `vendor` value this candidate would need in a fleet file. Note
|
||||||
|
* `octoprint` where a probe saw only the OctoPrint API: PrusaLink serves
|
||||||
|
* that same API, so claiming PrusaLink would assert more than was
|
||||||
|
* observed - see `ambiguous_with`.
|
||||||
|
*/
|
||||||
|
vendor: DiscoveredVendor,
|
||||||
|
/**
|
||||||
|
* An IP or hostname for a network printer, or a device path such as
|
||||||
|
* `/dev/ttyACM0` for one on a serial port. An address on the *gateway's*
|
||||||
|
* network: not a URL, and not reachable from the cloud.
|
||||||
|
*/
|
||||||
|
address: Type.String({ minLength: 1, maxLength: 255 }),
|
||||||
|
/** Only where a probe could learn it without a credential. */
|
||||||
|
serial: Maybe(Type.String({ maxLength: 64 })),
|
||||||
|
model: Maybe(Type.String({ maxLength: 128 })),
|
||||||
|
/** Whatever the operator typed into their slicer. Display only. */
|
||||||
|
name: Maybe(Type.String({ maxLength: 128 })),
|
||||||
|
/** Human detail to recognise the machine by. Never parsed. */
|
||||||
|
detail: Maybe(Type.String({ maxLength: 256 })),
|
||||||
|
/**
|
||||||
|
* A vendor this could equally be. Today only `prusa_link` versus
|
||||||
|
* `octoprint`, which nothing on the network distinguishes. Present means
|
||||||
|
* the operator must choose, and the web app should offer both rather than
|
||||||
|
* quietly picking one.
|
||||||
|
*/
|
||||||
|
ambiguous_with: Maybe(DiscoveredVendor),
|
||||||
|
/** Which probe found it, which bounds how much it can know. */
|
||||||
|
source: Type.Union([
|
||||||
|
Type.Literal("ssdp"),
|
||||||
|
Type.Literal("certificate"),
|
||||||
|
Type.Literal("http_api"),
|
||||||
|
Type.Literal("usb_descriptor"),
|
||||||
|
Type.Literal("serial_handshake"),
|
||||||
|
]),
|
||||||
|
/**
|
||||||
|
* Whether this gateway build can actually drive this vendor. False means
|
||||||
|
* the client is still a stub, so adding the printer would produce one that
|
||||||
|
* connects and does nothing - worth saying in the UI rather than offering
|
||||||
|
* it as ready.
|
||||||
|
*/
|
||||||
|
driver_ready: Type.Boolean(),
|
||||||
|
}, { additionalProperties: false });
|
||||||
|
export type DiscoveredPrinter = Static<typeof DiscoveredPrinterSchema>;
|
||||||
|
|
||||||
export const EdgeClientMessageSchema = Type.Union([
|
export const EdgeClientMessageSchema = Type.Union([
|
||||||
Type.Object({
|
Type.Object({
|
||||||
type: Type.Literal("hello"),
|
type: Type.Literal("hello"),
|
||||||
gateway_id: Type.String(),
|
gateway_id: Type.String(),
|
||||||
farm_id: Type.String(),
|
farm_id: Type.String(),
|
||||||
token: Type.String(),
|
/*
|
||||||
|
* No credential here, deliberately. The edge JWT travels in an
|
||||||
|
* `Authorization: Bearer` header on the upgrade request and is verified
|
||||||
|
* before the socket exists. A `token` field used to live on this
|
||||||
|
* message; it was removed, because a token in the first frame means a
|
||||||
|
* live socket and a parsed message for a peer that has proved nothing.
|
||||||
|
*
|
||||||
|
* `farm_id` is redundant with the token's own claim, and is checked
|
||||||
|
* against it rather than trusted.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Where this gateway serves camera restreams, e.g.
|
||||||
|
* `http://gateway.local:1984`. Omitted by a gateway with no cameras or
|
||||||
|
* no go2rtc.
|
||||||
|
*
|
||||||
|
* Reported rather than configured, because only the gateway knows the
|
||||||
|
* address it is reachable at - and note what that address is: a LAN one.
|
||||||
|
* A browser on the same network can play the stream; one outside it
|
||||||
|
* cannot, and a relay for that does not exist yet.
|
||||||
|
*/
|
||||||
|
stream_base_url: Maybe(Type.String()),
|
||||||
}),
|
}),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
type: Type.Literal("heartbeat"),
|
type: Type.Literal("heartbeat"),
|
||||||
@@ -137,6 +279,23 @@ export const EdgeClientMessageSchema = Type.Union([
|
|||||||
/** Only when `ok` is false. A message for an operator, not a code. */
|
/** Only when `ok` is false. A message for an operator, not a code. */
|
||||||
error: Maybe(Type.String()),
|
error: Maybe(Type.String()),
|
||||||
}),
|
}),
|
||||||
|
/** go2rtc's answer SDP for a `webrtc_offer`, passed through verbatim. */
|
||||||
|
Type.Object({
|
||||||
|
type: Type.Literal("webrtc_answer"),
|
||||||
|
session_id: Type.String(),
|
||||||
|
sdp: Type.String(),
|
||||||
|
}),
|
||||||
|
/**
|
||||||
|
* No answer was obtainable: no such camera, go2rtc not running, or it
|
||||||
|
* refused the offer. A variant of its own rather than an empty answer, so
|
||||||
|
* a viewer is told something instead of watching a `<video>` that never
|
||||||
|
* loads.
|
||||||
|
*/
|
||||||
|
Type.Object({
|
||||||
|
type: Type.Literal("webrtc_error"),
|
||||||
|
session_id: Type.String(),
|
||||||
|
message: Type.String(),
|
||||||
|
}),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
type: Type.Literal("frame_batch"),
|
type: Type.Literal("frame_batch"),
|
||||||
printer_id: Type.String(),
|
printer_id: Type.String(),
|
||||||
@@ -148,6 +307,26 @@ export const EdgeClientMessageSchema = Type.Union([
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* Printers the gateway found on its own network or USB ports that are not
|
||||||
|
* in its fleet file, so an operator can add one in the web app without
|
||||||
|
* hunting for an IP address.
|
||||||
|
*
|
||||||
|
* A whole batch that replaces whatever this gateway reported before, not a
|
||||||
|
* set of additions: a printer that has gone is reported by its absence,
|
||||||
|
* and a gateway that sees nothing sends an empty list. Only sent at all
|
||||||
|
* when discovery is enabled in the gateway's own config, which it is not
|
||||||
|
* by default - scanning a customer's network is something they ask for.
|
||||||
|
*/
|
||||||
|
Type.Object({
|
||||||
|
type: Type.Literal("discovered_printers"),
|
||||||
|
printers: Type.Array(DiscoveredPrinterSchema, { maxItems: 256 }),
|
||||||
|
/**
|
||||||
|
* When the scan finished, so the cloud can show the age of the list
|
||||||
|
* rather than implying it is live.
|
||||||
|
*/
|
||||||
|
scanned_at: Maybe(Type.String({ format: "date-time" })),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
export type EdgeClientMessage = Static<typeof EdgeClientMessageSchema>;
|
export type EdgeClientMessage = Static<typeof EdgeClientMessageSchema>;
|
||||||
|
|
||||||
@@ -155,6 +334,39 @@ export const EdgeServerMessageSchema = Type.Union([
|
|||||||
Type.Object({ type: Type.Literal("hello_ack"), session_id: Type.String() }),
|
Type.Object({ type: Type.Literal("hello_ack"), session_id: Type.String() }),
|
||||||
Type.Object({ type: Type.Literal("hello_error"), message: Type.String() }),
|
Type.Object({ type: Type.Literal("hello_error"), message: Type.String() }),
|
||||||
Type.Object({ type: Type.Literal("heartbeat_ack"), seq: Type.Integer({ minimum: 0 }) }),
|
Type.Object({ type: Type.Literal("heartbeat_ack"), seq: Type.Integer({ minimum: 0 }) }),
|
||||||
|
/**
|
||||||
|
* Relay a browser's WebRTC offer to this gateway's go2rtc and send the
|
||||||
|
* answer back. **Signalling only - no video crosses this socket.** The
|
||||||
|
* gateway POSTs the offer to its own go2rtc over loopback, which go2rtc
|
||||||
|
* exempts from its API password, so no credential is needed or sent;
|
||||||
|
* browser and go2rtc then negotiate media directly over ICE.
|
||||||
|
*
|
||||||
|
* It exists because a browser cannot signal to a gateway itself. Measured
|
||||||
|
* against go2rtc 1.9.14: it sends no `Access-Control-Allow-Origin` on
|
||||||
|
* anything, and checks its password before CORS, so the unauthenticated
|
||||||
|
* preflight a browser always sends gets a 401.
|
||||||
|
*
|
||||||
|
* Relaying only the signalling is the point. The media stays
|
||||||
|
* peer-to-peer - on a workshop LAN it never leaves the building and costs
|
||||||
|
* no cloud egress - and go2rtc's answer carries a server-reflexive
|
||||||
|
* candidate as well as its host ones, so a remote viewer can often
|
||||||
|
* hole-punch straight to the gateway. A TURN server in go2rtc's
|
||||||
|
* `ice_servers` covers the NATs where that fails, and that is
|
||||||
|
* configuration rather than a change to this protocol.
|
||||||
|
*/
|
||||||
|
Type.Object({
|
||||||
|
type: Type.Literal("webrtc_offer"),
|
||||||
|
/** Correlates the answer with this offer. One negotiation, not a subscription. */
|
||||||
|
session_id: Type.String(),
|
||||||
|
/**
|
||||||
|
* The go2rtc stream name, which is what `PrinterTelemetry.camera_stream`
|
||||||
|
* reported. Deliberately not called a printer id: the two are equal
|
||||||
|
* today, but one is the gateway's own naming and the other is ours.
|
||||||
|
*/
|
||||||
|
camera_stream: Type.String(),
|
||||||
|
/** The browser's offer SDP, passed through verbatim. */
|
||||||
|
sdp: Type.String(),
|
||||||
|
}, { additionalProperties: false }),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
type: Type.Literal("command"),
|
type: Type.Literal("command"),
|
||||||
command_id: Type.String(),
|
command_id: Type.String(),
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"command": { "const": "push_status" },
|
"command": { "const": "push_status" },
|
||||||
|
|
||||||
|
"msg": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Whether this push is the whole object or only what changed: 0 forced/full (what a pushing.pushall triggers), 1 automatic/partial. Confirmed against a real printer. A v1 consumer MUST read this - merging a partial push as if it were full leaves the picture complete, but replacing the cached object with a partial one silently unsets every field the push did not mention. It cannot be inferred any other way, because push_status does not echo sequence_id (see the module description), so there is no correlation back to the pushall that asked for it. v2 sends the whole object every time and this field is informational there."
|
||||||
|
},
|
||||||
|
|
||||||
"upgrade_state": { "$ref": "../../common/upgrade_state.schema.json" },
|
"upgrade_state": { "$ref": "../../common/upgrade_state.schema.json" },
|
||||||
"ipcam": { "$ref": "../../common/ipcam.schema.json" },
|
"ipcam": { "$ref": "../../common/ipcam.schema.json" },
|
||||||
"upload": { "$ref": "../../common/upload.schema.json" },
|
"upload": { "$ref": "../../common/upload.schema.json" },
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
{ "$ref": "../../../common/envelope.schema.json" },
|
{ "$ref": "../../../common/envelope.schema.json" },
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"$comment": "There is deliberately no vt_tray property here. A real v2 printer omits the key entirely rather than sending an empty object, confirmed live, so absence means 'not reported' and not 'no external spool' - do not add it to make this symmetrical with v1. It is why the external-spool count cannot be derived from a report: counting reported spools would give 0 on exactly the dual-nozzle H2C that has two (see common/ams/ams_mapping.schema.json).",
|
||||||
"properties": {
|
"properties": {
|
||||||
"command": { "const": "push_status" },
|
"command": { "const": "push_status" },
|
||||||
|
|
||||||
|
|
||||||
"2D": { "$ref": "./_2d_report.schema.json" },
|
"2D": { "$ref": "./_2d_report.schema.json" },
|
||||||
"3D": { "$ref": "./_3d_report.schema.json" },
|
"3D": { "$ref": "./_3d_report.schema.json" },
|
||||||
"ams": { "$ref": "../../common/ams/ams.schema.json" },
|
"ams": { "$ref": "../../common/ams/ams.schema.json" },
|
||||||
@@ -85,7 +87,10 @@
|
|||||||
"mc_stage": { "type": "integer" },
|
"mc_stage": { "type": "integer" },
|
||||||
|
|
||||||
"model_id": { "type": "string" },
|
"model_id": { "type": "string" },
|
||||||
"msg": { "type": "integer" },
|
"msg": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Full-vs-partial indicator, as in v1 (0 forced/full, 1 automatic/partial) - but informational here, because v2 sends the entire object on every push. The v1 schema carries the full note on why a v1 consumer must read it."
|
||||||
|
},
|
||||||
|
|
||||||
"percent": { "type": "integer" },
|
"percent": { "type": "integer" },
|
||||||
"plate_cnt": { "type": "integer" },
|
"plate_cnt": { "type": "integer" },
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/common/envelope.schema.json",
|
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/common/envelope.schema.json",
|
||||||
"title": "MessageEnvelope",
|
"title": "MessageEnvelope",
|
||||||
|
"description": "Row provenance shared by the continuum/v1 domain types that $ref it: vendor, material, spool, printer_info, firmware_info and module_info. The _by fields are the actor's id, not a timestamp - they were typed as date-time here, copied from the _at lines above them, which would have rejected every real value the moment anything started validating against this.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "format": "uuid" },
|
"id": { "type": "string", "format": "uuid" },
|
||||||
"created_at": { "type": "string", "format": "date-time" },
|
"created_at": { "type": "string", "format": "date-time" },
|
||||||
"created_by": { "type": "string", "format": "date-time" },
|
"created_by": { "type": "string", "format": "uuid" },
|
||||||
"updated_at": { "type": "string", "format": "date-time" },
|
"updated_at": { "type": "string", "format": "date-time" },
|
||||||
"updated_by": { "type": "string", "format": "date-time" }
|
"updated_by": { "type": "string", "format": "uuid" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "Hello",
|
"title": "Hello",
|
||||||
"description": "First message on every connection, before anything else is accepted. Carries the edge JWT minted by POST /auth/edge-token.",
|
"description": "First message on every connection, before anything else is accepted. Carries NO credential: the edge JWT minted by POST /auth/edge-token goes in an Authorization: Bearer header on the WebSocket upgrade request, and is verified before the socket exists, so an unauthenticated peer gets an HTTP 401 and no socket at all. This message used to carry a 'token' field; it was removed, because a token in the first frame means a live socket and a parsed message for a peer that has proved nothing. farm_id here is redundant with the token's own claim and is checked against it rather than trusted - a gateway naming a farm its token was not minted for is refused.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"const": "hello"
|
"const": "hello"
|
||||||
@@ -18,15 +18,16 @@
|
|||||||
"farm_id": {
|
"farm_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"token": {
|
"stream_base_url": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"description": "Where this gateway serves camera restreams, e.g. http://gateway.local:1984 - the base of a go2rtc instance the gateway supervises. Optional: a gateway with no cameras configured, or with go2rtc not installed, omits it. Reported by the gateway rather than configured in the cloud because only the gateway knows the address it is actually reachable at. NOTE the consequence, which is a real limit and not a bug: this is a LAN address. A browser on the same network as the gateway can play the stream directly; one outside it cannot, and making that work needs a relay that does not exist yet. Anything surfacing this to a user has to cope with it being unreachable."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"type",
|
"type",
|
||||||
"gateway_id",
|
"gateway_id",
|
||||||
"farm_id",
|
"farm_id"
|
||||||
"token"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -139,6 +140,74 @@
|
|||||||
"printer_id",
|
"printer_id",
|
||||||
"frames"
|
"frames"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"title": "WebrtcAnswer",
|
||||||
|
"description": "go2rtc's answer SDP for a WebrtcOffer, passed through verbatim.",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "webrtc_answer"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sdp": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"session_id",
|
||||||
|
"sdp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"title": "WebrtcError",
|
||||||
|
"description": "The gateway could not get an answer: no such camera, go2rtc not running, or it refused the offer. A variant of its own rather than an empty answer, so the viewer is told something instead of watching a video element that never loads.",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "webrtc_error"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"session_id",
|
||||||
|
"message"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"title": "DiscoveredPrinters",
|
||||||
|
"description": "Printers the gateway found on its own network or USB ports that are not in its fleet file, so an operator can add one in the web app without first reading an IP address or a serial off a machine. Sent as a whole batch and treated as a replacement for what this gateway previously reported, not as additions: a printer that has gone is reported by its absence, and a gateway that can see nothing sends an empty list. Only sent when discovery is enabled in the gateway's own config, which it is NOT by default - probing a customer's network is something they have to ask for.",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "discovered_printers"
|
||||||
|
},
|
||||||
|
"printers": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 256,
|
||||||
|
"items": {
|
||||||
|
"$ref": "./discovered_printer.schema.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scanned_at": {
|
||||||
|
"description": "When the gateway finished the scan these results came from, so the cloud can show the age of the list rather than implying it is live.",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"printers"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://schemas.continuum.dev/continuum/v1/edge/discovered_printer.schema.json",
|
||||||
|
"title": "DiscoveredPrinter",
|
||||||
|
"description": "A printer a gateway found on its own network or USB ports that is NOT in its fleet file - so it is a candidate for an operator to add, not a printer the gateway can drive. It deliberately carries no credential: a Bambu access code is on the printer's screen and a PrusaLink API key is in its web UI, and no probe can learn either. The point of sending these to the cloud is that an operator adding a printer in the web app should not have to go and read an IP address or a 15-character serial off a machine. Every string here is reported by a device on the customer's network and must be treated as untrusted display text - hence the length caps - because anything that answers an SSDP query or an HTTP probe chooses its own name.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"vendor": {
|
||||||
|
"description": "The `vendor` value this candidate would need in the gateway's fleet file. `octoprint` rather than `prusa_link` is the honest answer when a probe saw only the OctoPrint API, which PrusaLink also serves - see `ambiguous_with`.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["bambu", "klipper", "prusa_link", "octoprint", "prusa_serial"]
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"description": "Where the gateway found it: an IP or hostname for a network printer, or a device path like `/dev/ttyACM0` for one on a serial port. Not a URL, and not reachable from the cloud - it is an address on the gateway's network, shown so an operator can recognise which machine this is.",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 255
|
||||||
|
},
|
||||||
|
"serial": {
|
||||||
|
"description": "The printer's serial number where the probe could learn it without a credential - which today means a Bambu printer's TLS certificate CN, or a Marlin board's `UUID`. Absent is normal and not an error.",
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"description": "The vendor's own model code or machine type, e.g. Bambu's `O1C2` or Marlin's `MACHINE_TYPE`.",
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "The name the printer reports for itself, which is usually whatever the operator typed into their slicer or set as the host's hostname. Display only.",
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"description": "Free-form human detail to recognise the machine by - a firmware version, a USB vendor string, or why a probe could not say more. Never parsed, and never written into a config file.",
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 256
|
||||||
|
},
|
||||||
|
"ambiguous_with": {
|
||||||
|
"description": "A vendor this could equally be, when the probe genuinely cannot tell. Today only `prusa_link` versus `octoprint`: PrusaLink implements OctoPrint's API, so without a credential nothing on the network distinguishes them. Present means the operator has to choose; the web app should offer both rather than picking one.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["bambu", "klipper", "prusa_link", "octoprint", "prusa_serial"]
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"description": "Which probe found it, which bounds how much it can know: `certificate` and `usb_descriptor` are passive reads, `ssdp` is an unsolicited announcement, `http_api` an identity endpoint, and `serial_handshake` an `M115` that reset the board to ask.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["ssdp", "certificate", "http_api", "usb_descriptor", "serial_handshake"]
|
||||||
|
},
|
||||||
|
"driver_ready": {
|
||||||
|
"description": "Whether this gateway build can actually drive this vendor. False means the printer was found and reported but its client is still a stub, so adding it would produce a printer that connects and does nothing - the web app should say so rather than offering it as ready to use.",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["vendor", "address", "source", "driver_ready"]
|
||||||
|
}
|
||||||
@@ -2,13 +2,48 @@
|
|||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/edge/printer_command.schema.json",
|
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/edge/printer_command.schema.json",
|
||||||
"title": "PrinterCommand",
|
"title": "PrinterCommand",
|
||||||
"description": "The closed set of things the cloud may ask a printer to do. Deliberately small, and deliberately missing the obvious one: there is no 'start a print' variant. Starting an uploaded file has now been confirmed by hand on a real P1P (2026-10-11), so the earlier reason for leaving it out is spent; what is still missing is the rest of the path around it - nothing uploads a sliced file to a gateway, and no job-dispatch route decides which printer should run which job. A start variant belongs here once there is something to start, not before.",
|
"description": "The closed set of things the cloud may ask a printer to do. Deliberately small. It now includes start_print, which was held back until there was a job path to drive it; the thing to understand about that variant is that its acceptance says almost nothing - see its own description. Every variant is closed (additionalProperties: false), unlike printer_telemetry which is deliberately open: telemetry is written by gateways in the field and read liberally, while a command is written by the backend, is a handful of fields, and moves a physical machine - an unrecognised field there is a bug on the sending side, and silently dropping it would let a caller misspell a field and watch its command succeed while doing something else.",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"title": "StartPrint",
|
||||||
|
"description": "Print a job's file. The gateway downloads it from `url`, transfers it to the printer, and starts it. Three things about this variant that a caller must not get wrong. (1) Its result means the gateway accepted the job, not that anything is printing: a real end-to-end run on a P1P (2026-10-15) was accepted, uploaded byte-perfect, reached RUNNING, and then the printer paused itself on a physical filament fault. Job state has to be driven by the telemetry that follows, never by this reply. (2) There is no filament or AMS field on purpose. The gateway reads the mapping out of the project archive's own Metadata/slice_info.config, because the archive is the only thing that knows what each plate was sliced for - guessing it instead is what made that same printer heat to 250C and pause itself at 1%. (3) `url` is short-lived and must be presigned at dispatch time, not at job creation: a queued job may wait days, and a URL minted when it was queued would be expired by the time anything sent it.",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"const": "start_print"
|
||||||
|
},
|
||||||
|
"job_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "The cloud's print_jobs.id. The gateway does not need it to do the work - it is here so that a gateway's logs can be lined up against a job without reading its own telemetry back out of the cloud."
|
||||||
|
},
|
||||||
|
"file_name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 500,
|
||||||
|
"description": "What to call the file on the printer's own storage. A `.3mf` is treated as a sliced project (filament mapping derived from inside it); anything else is treated as bare gcode, which carries no mapping and prints with whatever its own tool changes select."
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"description": "A presigned, expiring GET for the job file. Deliberately a URL rather than the bytes: the file is tens of megabytes, this is a JSON control channel, and handing the gateway a URL keeps object-store credentials out of every gateway on every farm. Expect it to be valid for minutes, and note that an upload to a printer is slow - 0.2 MiB/s measured over FTPS to a P1P - so the download must finish before the transfer starts, not stream through it."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action",
|
||||||
|
"job_id",
|
||||||
|
"file_name",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "SetFanSpeed",
|
"title": "SetFanSpeed",
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": { "const": "set_fan_speed" },
|
"action": {
|
||||||
|
"const": "set_fan_speed"
|
||||||
|
},
|
||||||
"percent": {
|
"percent": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
@@ -16,32 +51,52 @@
|
|||||||
"description": "A percentage on this wire regardless of what the vendor wants. Each gateway converts: for Bambu that means an M106 PWM value over MQTT, which the printer then reports back as one of 16 fan gears - so a value set here does not read back identically."
|
"description": "A percentage on this wire regardless of what the vendor wants. Each gateway converts: for Bambu that means an M106 PWM value over MQTT, which the printer then reports back as one of 16 fan gears - so a value set here does not read back identically."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["action", "percent"]
|
"required": [
|
||||||
|
"action",
|
||||||
|
"percent"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "ControlPrint",
|
"title": "ControlPrint",
|
||||||
"description": "Pause, resume or stop the print the printer is currently running. Confirmed live on a P1P (2026-10-11): all three are accepted and a stop during PREPARE/RUNNING moved the printer to FAILED, which is how Bambu reports an operator-cancelled print. Note what the printer's acceptance does not mean - all three answered success while it was IDLE with nothing to pause, so a caller that needs to know the state actually changed must read telemetry back rather than trust the command result.",
|
"description": "Pause, resume or stop the print the printer is currently running. Confirmed live on a P1P (2026-10-11): all three are accepted and a stop during PREPARE/RUNNING moved the printer to FAILED, which is how Bambu reports an operator-cancelled print. Note what the printer's acceptance does not mean - all three answered success while it was IDLE with nothing to pause, so a caller that needs to know the state actually changed must read telemetry back rather than trust the command result.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": { "const": "control_print" },
|
"action": {
|
||||||
|
"const": "control_print"
|
||||||
|
},
|
||||||
"control": {
|
"control": {
|
||||||
"enum": ["pause", "resume", "stop"],
|
"enum": [
|
||||||
|
"pause",
|
||||||
|
"resume",
|
||||||
|
"stop"
|
||||||
|
],
|
||||||
"description": "Stop is a cancel, not a pause: it ends the print and it cannot be undone. A UI should treat it accordingly."
|
"description": "Stop is a cancel, not a pause: it ends the print and it cannot be undone. A UI should treat it accordingly."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["action", "control"]
|
"required": [
|
||||||
|
"action",
|
||||||
|
"control"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "SendGcodeLine",
|
"title": "SendGcodeLine",
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": { "const": "send_gcode_line" },
|
"action": {
|
||||||
|
"const": "send_gcode_line"
|
||||||
|
},
|
||||||
"line": {
|
"line": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "A single raw gcode line. By far the most powerful variant, and the one to be most careful about ever exposing to a UI."
|
"description": "A single raw gcode line. By far the most powerful variant, and the one to be most careful about ever exposing to a UI."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["action", "line"]
|
"required": [
|
||||||
|
"action",
|
||||||
|
"line"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,10 @@
|
|||||||
],
|
],
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"description": "How many Health-Management-System entries the printer currently has active. A count only for now - decoding the {attr, code} pairs into messages needs Bambu's HMS tables, which Continuum does not have. Readers must accept an explicit null here as equivalent to the key being absent: a gateway omits what it has no value for, but a serde-derived Rust producer emits null unless every field is annotated, and rejecting a whole batch over that is not worth it."
|
"description": "How many Health-Management-System entries the printer currently has active. A count only for now - decoding the {attr, code} pairs into messages needs Bambu's HMS tables, which Continuum does not have. Readers must accept an explicit null here as equivalent to the key being absent: a gateway omits what it has no value for, but a serde-derived Rust producer emits null unless every field is annotated, and rejecting a whole batch over that is not worth it."
|
||||||
|
},
|
||||||
|
"camera_stream": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The stream name to ask the gateway's stream_base_url for, when this printer has a camera the gateway could restream. Absent means no stream: either the printer reports no camera source, or it is a model whose camera is not reachable this way. That second case is the common one today - a Bambu P1/A1 does not publish an RTSP URL at all (confirmed: a P1P's ipcam block has ipcam_dev and ipcam_record and no rtsp_url), and only the X1/H2 class does. Those need Bambu's proprietary port-6000 JPEG stream instead, which is a separate client this gateway does not have yet."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -8,48 +8,107 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "HelloAck",
|
"title": "HelloAck",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": { "const": "hello_ack" },
|
"type": {
|
||||||
"session_id": { "type": "string" }
|
"const": "hello_ack"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["type", "session_id"]
|
"required": [
|
||||||
|
"type",
|
||||||
|
"session_id"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "HelloError",
|
"title": "HelloError",
|
||||||
"description": "The handshake was rejected. The connection closes immediately after this.",
|
"description": "The handshake was rejected. The connection closes immediately after this.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": { "const": "hello_error" },
|
"type": {
|
||||||
"message": { "type": "string" }
|
"const": "hello_error"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["type", "message"]
|
"required": [
|
||||||
|
"type",
|
||||||
|
"message"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "HeartbeatAck",
|
"title": "HeartbeatAck",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": { "const": "heartbeat_ack" },
|
"type": {
|
||||||
|
"const": "heartbeat_ack"
|
||||||
|
},
|
||||||
"seq": {
|
"seq": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"description": "Echoes the seq from the heartbeat being acknowledged."
|
"description": "Echoes the seq from the heartbeat being acknowledged."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["type", "seq"]
|
"required": [
|
||||||
|
"type",
|
||||||
|
"seq"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "Command",
|
"title": "Command",
|
||||||
"description": "Do something to one printer on this gateway. Answered with a client command_result carrying the same command_id.",
|
"description": "Do something to one printer on this gateway. Answered with a client command_result carrying the same command_id.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": { "const": "command" },
|
"type": {
|
||||||
"command_id": { "type": "string" },
|
"const": "command"
|
||||||
|
},
|
||||||
|
"command_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"printer_id": {
|
"printer_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The gateway's own printer id, not the backend UUID - the backend translates on the way out, so a gateway never has to know its printers' cloud identities."
|
"description": "The gateway's own printer id, not the backend UUID - the backend translates on the way out, so a gateway never has to know its printers' cloud identities."
|
||||||
},
|
},
|
||||||
"command": { "$ref": "./printer_command.schema.json" }
|
"command": {
|
||||||
|
"$ref": "./printer_command.schema.json"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["type", "command_id", "printer_id", "command"]
|
"required": [
|
||||||
|
"type",
|
||||||
|
"command_id",
|
||||||
|
"printer_id",
|
||||||
|
"command"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"title": "WebrtcOffer",
|
||||||
|
"description": "Relay a browser's WebRTC offer to this gateway's go2rtc and send the answer back. SIGNALLING ONLY - no video crosses this socket. The gateway POSTs the offer to its own go2rtc over loopback (which go2rtc exempts from its API password, so no credential is needed or sent) and returns the answer as a WebrtcAnswer, after which the browser and go2rtc negotiate media directly over ICE. This exists because a browser cannot signal to a gateway itself: measured against go2rtc 1.9.14, it sends no CORS headers on anything and checks its password before CORS, so the unauthenticated preflight a browser always sends gets a 401. Relaying only the signalling keeps the media peer-to-peer - on a workshop LAN it never leaves the building, and go2rtc also offers a server-reflexive candidate, so a remote viewer can often hole-punch straight to the gateway. A TURN server in go2rtc's ice_servers is the fallback for the NATs where that fails; it is configuration, not a change to this protocol.",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "webrtc_offer"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Correlates the answer with this offer. One negotiation, not a lasting subscription."
|
||||||
|
},
|
||||||
|
"camera_stream": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The go2rtc stream name, which is what PrinterTelemetry.camera_stream reported. Deliberately not called a printer id: the two are equal today, but one is the gateway's own naming and the other is the cloud's."
|
||||||
|
},
|
||||||
|
"sdp": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The browser's offer SDP, passed through verbatim."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"session_id",
|
||||||
|
"camera_stream",
|
||||||
|
"sdp"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user