Compare commits

...
10 Commits
Author SHA1 Message Date
octoturgeandClaude Opus 5 71f8ea94b7 Add a discovered_printers gateway message
A gateway can find printers on its own network and USB ports, but nothing
carried that back to the cloud, so adding one in the web app still meant an
operator reading an IP address and a serial number off a machine by hand.

Two schemas, both gateway -> cloud:

- `DiscoveredPrinter`, a candidate the gateway found but cannot drive,
  because it is not in that gateway's fleet file.
- A `discovered_printers` variant on the client message, carrying a whole
  batch that replaces what that gateway last reported. A printer that has
  gone is reported by its absence, and a gateway seeing nothing sends an
  empty list.

A candidate deliberately 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, so
no probe learns either. What this buys is the address and the identity, which
are the parts an operator would otherwise have to go and look up.

Two details worth keeping in mind downstream:

`ambiguous_with` exists because PrusaLink serves OctoPrint's API, so a probe
that saw only that API cannot honestly claim PrusaLink. The candidate says
`octoprint` and names the alternative, and the operator settles it.

Every string here was chosen by a device on the customer's network - anything
answering an SSDP query or an HTTP probe names itself - so they are untrusted
display strings. Hence the length caps, and hence `DiscoveredPrinter` being
closed where its sibling variants are open: an unbounded LAN-supplied `name`
should not reach a database column or a React tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 08:04:27 +00:00
octoturgeandClaude Opus 5 ab9274497e Add WebRTC signalling to the edge protocol
A browser cannot signal to a gateway's go2rtc directly. Measured against
go2rtc 1.9.14: it sends no Access-Control-Allow-Origin on any response,
the SDP POST needs a preflight anyway because application/sdp is not a
CORS-safelisted content type, and it checks its API password before CORS -
so the preflight, which a browser always sends without credentials, gets
a 401. A gateway is behind a workshop NAT besides, so nothing can dial in.

So the cloud relays the SDP exchange over the socket the gateway already
holds open: webrtc_offer down, webrtc_answer or webrtc_error back up.

Signalling only - no video on this socket, and that is the design rather
than a limitation of it. Media goes browser-to-gateway through a TURN
relay, DTLS-SRTP encrypted, so neither the backend nor the relay can
read it. The alternative was tunnelling fMP4 up this socket and
streaming it from the API, which would have meant plaintext video
through the cloud and - since <video> cannot send an Authorization
header - a credential in a URL, the pattern /ws/telemetry rejected.

webrtc_error is its own variant rather than an empty answer because the
cloud holds a browser's request open on this, and a silent failure there
is a viewer watching a video element that never loads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 00:32:57 +00:00
octoturgeandClaude Opus 5 14cf7feb88 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-18 19:36:45 +00:00
octoturgeandClaude Opus 5 43242c0410 Move the edge credential out of the hello message
The `hello` frame carried a `token` field holding the edge JWT. It is
gone: the token now travels in an `Authorization: Bearer` header on the
WebSocket upgrade request.

A token in the first frame means the backend has already accepted a
socket, allocated state for it and run its JSON through this schema and
a switch statement for a peer that has proved nothing. Every message type
refused to act without a verified farm, so nothing could be done with
such a connection - but "it cannot do anything" is a weaker guarantee
than "it does not exist", and it needed a timeout to stop anyone holding
sockets open for free. Verified at the handshake, an unauthenticated peer
gets an HTTP 401 and no socket at all.

The alternative of a `?token=` query parameter was rejected outright: a
bearer credential in a URL lands in access logs, proxy logs and error
breadcrumbs, which is exactly what continuum-proxy's hand-written `Debug`
for `PrinterCommand` exists to avoid for presigned URLs. `Authorization`
is the header every log tool already knows to redact.

`farm_id` stays on the message even though the token carries the same
claim. It is checked against the token rather than trusted, so sending it
is how a misconfigured gateway gets caught instead of silently working
against the wrong farm.

A browser cannot set headers on a WebSocket, so a future user-facing
socket cannot copy this exactly; its equivalent is the subprotocol field,
`new WebSocket(url, ["bearer", token])`, which is also verified at the
upgrade.

Breaking change to the edge protocol, which is free right now: nothing is
deployed. `npm run typecheck` and `npm run build` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-17 13:59:41 +00:00
octoturgeandClaude Opus 5 9b8f0c2308 Carry camera stream information on the edge protocol
Two optional fields so a user can be pointed at a printer's camera.

`hello.stream_base_url` is where the gateway serves restreams - the base of
the go2rtc instance it already supervises. Reported by the gateway rather
than configured in the cloud, because only the gateway knows the address it
is actually reachable at.

That address is a LAN one, and the schema says so where someone will read it
rather than leaving it to be discovered in a support ticket. A browser on the
same network as the gateway can play the stream; one outside it cannot, and
the relay that would fix it does not exist. For a print farm whose operator
is in the workshop this is the normal case and it works; for anyone else it
is a limit, and anything surfacing the field has to cope with it being
unreachable.

`printer_telemetry.camera_stream` is the stream's name, present only when
there is one. Absent is the common answer today and the reason is worth
recording: a Bambu P1/A1 publishes no RTSP URL at all - a real P1P's ipcam
block carries `ipcam_dev` and `ipcam_record` and nothing to connect to -
while an H2C reports `rtsps://<ip>:322/streaming/live/1`. So RTSP restreaming
covers the X1/H2 class and nothing else; the rest need Bambu's proprietary
port-6000 JPEG stream, which is a client no gateway has yet.

Both optional and additive, so existing gateways are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 22:55:52 +00:00
octoturgeandClaude Opus 5 a8552047c3 Close the printer command variants
Found by a test of mine that asserted the union was closed and then failed:
TypeBox objects permit unknown properties by default, so a `start_print`
carrying an `ams_mapping` validated and the field was silently dropped.

Every command variant is now `additionalProperties: false`, and
`printer_telemetry` deliberately stays open. The asymmetry follows from who
writes each message. Telemetry comes from gateways in the field and is read
liberally: an unknown field must not cost a whole farm's batch, and a gateway
newer than the backend should still be understood. A command is written by
the backend, is four fields, and moves a physical machine - there an
unrecognised field is a bug on the sending side, and dropping it silently is
the worst outcome available, because a caller that misspells a field watches
its command succeed while the machine does something else.

`ams_mapping` is the case that matters: it is deliberately absent from
`start_print` because the gateway reads the mapping out of the project
archive, and a caller passing one has misunderstood something that previously
cost a heated nozzle and a self-paused print. It is now told so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 22:53:45 +00:00
octoturgeandClaude Opus 5 9b57c22fff Add start_print to the printer command set
Held back until there was a job path to drive it; the queueing work needs it
now. Three things are deliberate about its shape.

It carries a presigned `url`, not the file's bytes. A sliced project runs to
tens of megabytes and this is a JSON control channel, and a URL also keeps
object-store credentials out of every gateway on every farm. The schema says
to presign it at *dispatch* time rather than at job creation, because a queued
job may wait days and a URL minted when it was queued would be long expired.

It carries no filament or AMS field. The gateway reads the mapping out of the
archive's own `Metadata/slice_info.config`, because the archive is the only
thing that knows what each plate was sliced for. Passing one in would
reintroduce the guess that made a real P1P heat to 250C and pause itself at
1%.

And its description is blunt about what accepting it means, which is: very
little. A full end-to-end run on 2026-10-15 was accepted, uploaded
byte-perfect, reached RUNNING, and then the printer paused itself on a
physical broken-filament fault. Job state has to be driven by the telemetry
that follows, never by this reply - so the schema says so where a caller will
read it, rather than leaving it to be rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 22:43:03 +00:00
octoturgeandClaude Opus 5 082a0cc53d Put the vt_tray note in $comment, not in properties
The note added a moment ago sat inside `properties` as
`"_comment_no_vt_tray": "..."`, which declares a property whose schema is a
string - and in draft 2020-12 a subschema has to be an object or a boolean.
It parses as JSON and reads fine, and a strict validator would reject the
whole schema. `$comment` is the keyword for this and belongs beside
`properties` rather than inside it.

Swept the rest of schemas/ for the same shape while here: every other
property value is a real subschema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:46:04 +00:00
octoturgeandClaude Opus 5 9b348902a7 Add msg to v1 push_status, and say why v2 has no vt_tray
Two things the live captures showed that the schemas did not say.

`msg` was missing from the v1 push_status schema, which is the one version
where it is load-bearing. v1 pushes only the fields that changed, except for
a pushall-triggered push which is the whole object, and `msg` is how a v1
message self-describes which it is (0 forced/full, 1 automatic/partial). A
consumer that replaces its cached object with a partial push silently unsets
every field that push did not mention, and there is no other way to tell:
push_status does not echo `sequence_id`, so there is no correlation back to
the pushall that asked for it. continuum-proxy has read this field since the
cache was written; the schema just never declared it.

v2 had it with no description. It is informational there - v2 sends the whole
object every time - so it now points at the v1 note rather than repeating it.

The other half is an absence, which a schema cannot express by staying
silent: v2 has no `vt_tray` because a real v2 printer omits the key entirely
rather than sending an empty object. That is recorded as a comment so nobody
adds it for symmetry with v1, because the distinction is the thing: absence
means "not reported", not "no external spool", and it is exactly why
`EXTERNAL_SPOOLS` in continuum-proxy is a constant instead of being counted
from a report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:45:38 +00:00
octoturgeandClaude Opus 5 03da12ad4e Type the envelope's _by fields as ids, not timestamps
`created_by` and `updated_by` in `continuum/common/envelope.schema.json` were
`format: date-time`, copied from the `_at` lines directly above them. They are
the id of whoever wrote the row. Nothing validates against this schema yet, so
nothing had failed - but it is $ref'd by six continuum/v1 domain types
(vendor, material, spool, printer_info, firmware_info, module_info), and the
first thing to validate one of them would have rejected every real value.

`format: uuid`, matching `id` in the same schema and the Supabase subject the
backend already treats as the actor.

No TS or Rust mirror to update: the ts-types package exports edge and ai only,
and the domain envelope has no generated counterpart on either side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:44:36 +00:00
12 changed files with 674 additions and 32 deletions
+98
View File
@@ -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>;
+216 -4
View File
@@ -54,6 +54,17 @@ export const PrinterTelemetrySchema = Type.Object({
* stage looks stuck.
*/
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 })),
total_layers: 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
* 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([
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({
action: Type.Literal("set_fan_speed"),
/**
@@ -83,7 +135,7 @@ export const PrinterCommandSchema = Type.Union([
* is set here does not read back identically.
*/
percent: Type.Integer({ minimum: 0, maximum: 100 }),
}),
}, { additionalProperties: false }),
Type.Object({
action: Type.Literal("control_print"),
/**
@@ -100,21 +152,111 @@ export const PrinterCommandSchema = Type.Union([
Type.Literal("resume"),
Type.Literal("stop"),
]),
}),
}, { additionalProperties: false }),
Type.Object({
action: Type.Literal("send_gcode_line"),
/** The most powerful variant by far; be careful about exposing it to a UI. */
line: Type.String(),
}),
}, { additionalProperties: false }),
]);
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([
Type.Object({
type: Type.Literal("hello"),
gateway_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: 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. */
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: Type.Literal("frame_batch"),
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>;
@@ -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_error"), message: Type.String() }),
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: Type.Literal("command"),
command_id: Type.String(),
+5
View File
@@ -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";
@@ -10,6 +10,11 @@
"properties": {
"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" },
"ipcam": { "$ref": "../../common/ipcam.schema.json" },
"upload": { "$ref": "../../common/upload.schema.json" },
@@ -7,9 +7,11 @@
{ "$ref": "../../../common/envelope.schema.json" },
{
"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": {
"command": { "const": "push_status" },
"2D": { "$ref": "./_2d_report.schema.json" },
"3D": { "$ref": "./_3d_report.schema.json" },
"ams": { "$ref": "../../common/ams/ams.schema.json" },
@@ -85,7 +87,10 @@
"mc_stage": { "type": "integer" },
"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" },
"plate_cnt": { "type": "integer" },
@@ -2,12 +2,13 @@
"$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",
"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",
"properties": {
"id": { "type": "string", "format": "uuid" },
"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_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",
"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": {
"type": {
"const": "hello"
@@ -18,15 +18,16 @@
"farm_id": {
"type": "string"
},
"token": {
"type": "string"
"stream_base_url": {
"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": [
"type",
"gateway_id",
"farm_id",
"token"
"farm_id"
]
},
{
@@ -139,6 +140,74 @@
"printer_id",
"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",
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/edge/printer_command.schema.json",
"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": [
{
"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",
"title": "SetFanSpeed",
"properties": {
"action": { "const": "set_fan_speed" },
"action": {
"const": "set_fan_speed"
},
"percent": {
"type": "integer",
"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."
}
},
"required": ["action", "percent"]
"required": [
"action",
"percent"
],
"additionalProperties": false
},
{
"type": "object",
"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.",
"properties": {
"action": { "const": "control_print" },
"action": {
"const": "control_print"
},
"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."
}
},
"required": ["action", "control"]
"required": [
"action",
"control"
],
"additionalProperties": false
},
{
"type": "object",
"title": "SendGcodeLine",
"properties": {
"action": { "const": "send_gcode_line" },
"action": {
"const": "send_gcode_line"
},
"line": {
"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."
}
},
"required": ["action", "line"]
"required": [
"action",
"line"
],
"additionalProperties": false
}
]
}
@@ -105,6 +105,10 @@
],
"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."
},
"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": [
@@ -8,48 +8,107 @@
"type": "object",
"title": "HelloAck",
"properties": {
"type": { "const": "hello_ack" },
"session_id": { "type": "string" }
"type": {
"const": "hello_ack"
},
"session_id": {
"type": "string"
}
},
"required": ["type", "session_id"]
"required": [
"type",
"session_id"
]
},
{
"type": "object",
"title": "HelloError",
"description": "The handshake was rejected. The connection closes immediately after this.",
"properties": {
"type": { "const": "hello_error" },
"message": { "type": "string" }
"type": {
"const": "hello_error"
},
"message": {
"type": "string"
}
},
"required": ["type", "message"]
"required": [
"type",
"message"
]
},
{
"type": "object",
"title": "HeartbeatAck",
"properties": {
"type": { "const": "heartbeat_ack" },
"type": {
"const": "heartbeat_ack"
},
"seq": {
"type": "integer",
"minimum": 0,
"description": "Echoes the seq from the heartbeat being acknowledged."
}
},
"required": ["type", "seq"]
"required": [
"type",
"seq"
]
},
{
"type": "object",
"title": "Command",
"description": "Do something to one printer on this gateway. Answered with a client command_result carrying the same command_id.",
"properties": {
"type": { "const": "command" },
"command_id": { "type": "string" },
"type": {
"const": "command"
},
"command_id": {
"type": "string"
},
"printer_id": {
"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."
},
"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
}
]
}