A gateway keeps what happened to a job it was handed (started, finished,
failed) and re-sends it on every connection until the cloud acknowledges
it, so an outcome reached while the cloud was unreachable still arrives.
camera_snapshot_request (cloud to gateway) and camera_snapshot (gateway
to cloud) carry one JPEG frame at a time; telemetry gains camera_snapshot
and the app view gains cameraSnapshot.
Adding a printer from the console has to put its LAN credential on the
gateway without the cloud keeping it. The new server messages carry it once
(documented as never-log) and are answered through the existing
command_result with the request id. The message cannot name a certificate
path or serial port: those are on the gateway's own disk and USB bus.
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.
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.
`/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.
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.
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.
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.
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.
Bambu's `stg_cur`: what a printer is doing when it is not laying down
plastic - bed levelling, nozzle cleaning, extrusion calibration. Passed
through as the printer's own number rather than decoded, because the
code-to-name table is not something we have. 0 means printing.
It is here because progress without it is ambiguous in a way that reads as a
fault. Confirmed on a P1P (2026-10-11): `percent` climbed to 14% while
`layer` stayed 0 for several minutes, the printer working through stages 54,
14 and 13 before starting layer 1. A console showing 14% at layer 0 with no
stage looks stuck, and an operator would reasonably go and power-cycle a
machine that was working perfectly.
Optional and additive, so gateways that predate it are unaffected.
Pause, resume and stop, as one variant carrying a verb rather than three
near-identical ones - matching the gateway's `PrintControl`, which exists so
there is one method across four vendor implementations instead of three.
Confirmed live on a P1P (2026-10-11), which is also where the two warnings
in the description come from. A stop is a cancel and is not undoable: the
printer reports the cancelled print as FAILED. And `result: "success"` from
the printer means the command was accepted, not that anything changed - all
three verbs answered success while it sat IDLE with nothing to pause - so a
caller that needs to know the state changed has to read telemetry back.
The enum is `pause|resume|stop` and deliberately not `cancel`, even though
`cancel` is Moonraker's word and the Klipper implementation behind this does
send a cancel. A verb only one vendor recognises must not validate on the
wire.
The "no start a print variant" note stays, with its reason corrected:
starting an uploaded file has now been confirmed by hand, so what is missing
is the path around it - nothing uploads a sliced file to a gateway and
nothing decides which printer runs which job.
`stream:camera_frames` and `channel:print_alerts` are the only interface
between `continuum-backend` and `continuum-ai-worker`, and there was
nothing specifying either. They had drifted completely: the backend
wrote `farmId`, `printerId`, `r2ObjectKey` and `capturedAt`; the worker
read a single field called `printer_id` and ignored the rest. Every task
was delivered, acknowledged, and attributed to a printer called
"unknown" - and nothing errored, because a stream entry is a flat string
map and asking it for a field that is not there is not a failure. Same
shape as the /ws/edge/v1 drift, found the same way.
`schemas/continuum/v1/ai/` is now canonical, with a TypeBox mirror in
ts-types and a Rust mirror in the worker.
Two things the schemas have to say that JSON Schema does not naturally
express, both of which caused real bugs:
- A Valkey stream entry is a flat field/value map of *strings*. No
nesting, no numbers, no null. So FrameTask has no numeric or nested
properties, and a field with no value is omitted from the XADD rather
than written as `""` - an empty string is a value, and the producer
was writing one for `print_job_id`, which a consumer would carry into
a lookup or a uuid column.
- PrintAlert, by contrast, *is* a JSON document, because PUBLISH carries
one opaque string. It is also fire-and-forget with no persistence, so
it can only ever be a live-console feed and never the record of a
detection.
The README's layout section was stale (it still listed four DTOs that
were removed in 2026-09) and did not mention `edge/` or `ai/` at all. It
now names both, says why the inter-service protocols live here, and
notes that their Rust mirrors sit in the single repo that consumes each
rather than in `packages/rust-types`.
continuum-proxy and continuum-backend each had their own idea of what the
edge WebSocket carried, and the two did not overlap anywhere: the backend
expected an `auth` handshake the gateway never sends, telemetry shaped one
printer per message where the gateway sends a batch, and UUID printer ids a
gateway has no way to know. Neither end had a command path that matched the
other. Nothing failed, because nothing was written down for both sides to
mirror. This is that document.
schemas/continuum/v1/edge/ is canonical: client_message, server_message,
printer_telemetry and printer_command. packages/ts-types gets its first
real TypeBox mirror - the placeholder there said to add one "once something
actually consumes it", and continuum-backend's edge route now does. The
Rust mirror stays in continuum-proxy/src/uplink/protocol.rs rather than
packages/rust-types, since the gateway is its only Rust consumer.
Two decisions worth recording:
Field names are snake_case, unlike the backend's HTTP routes. The other end
is serde-derived Rust, and snake_case is what those structs produce without
a rename attribute on every field. It is also what the rest of this
repository already uses.
Optional fields accept an explicit null as well as being absent. A gateway
omits what it has no value for, which is what these schemas describe - but
serde emits null by default unless every field is annotated, and a single
such field would otherwise fail validation and take a whole farm's
telemetry batch down with it. Strict in what we send, liberal in what we
accept; the contract tests on both sides pin down each half.
Also bumps this package's @sinclair/typebox to ^0.33.12 to match what
continuum-backend already pins. It was on ^0.32.0, which resolved to a
separate 0.32.37 install - two TypeBox versions means two incompatible
TSchema identities, and Elysia rejected every schema from here on those
grounds alone.
Replaces the old flat material/print_job/print_params/template schemas
with a richer structure: schemas/continuum/common/ for shared envelope +
enums (material_type taxonomy, printer_type), schemas/continuum/v1/
material/ (material + spool) and printer/ (printer_info/firmware_info/
module_info), plus a new vendor.schema.json.
Fixes found while verifying (all files added in this pass, so nothing
downstream depended on the broken paths yet):
- 5 broken $refs to common/envelope.schema.json - files under the new
v1/material/ and v1/printer/ subdirectories need ../../common/, not
../common/ (one more level of nesting than vendor.schema.json, which
sits directly under v1/).
- $id in 5 files (material.schema.json, spool.schema.json,
firmware_info/module_info/printer_info.schema.json) didn't match
their real path - mostly copy-paste from before the material/printer/
subdirectories existed.
- $id in all 13 files under common/material_type/ and
common/printer_type.enum.json said v1/ instead of common/.
- material.schema.json's hardness_value used minItems (an array-only
keyword, silently ignored on an integer) where minimum: 0 was
evidently intended, matching the maximum: 100 next to it.
Not fixed, needs a decision rather than a mechanical correction:
- spool.schema.json's `required` list is material.schema.json's
fields (material_type, min_nozzle_temp, ...), none of which exist in
spool's own properties (material, empty_spool_weight, ...) - copy-paste
left over from material.schema.json. continuum-schema-instance's error
message points right at it: "'material_type' is required but has no
schema in properties".
- envelope.schema.json's created_by/updated_by are typed
format: date-time (copy-pasted from created_at/updated_at) - almost
certainly meant to identify an actor, not a timestamp.
- printer_info.schema.json requires `host` via a oneOf (ipv4-or-ipv6
format) nested inside an allOf branch - continuum-schema-instance
doesn't merge a oneOf nested inside allOf (only plain
properties/required), so it currently builds an empty {} for this
schema; also worth confirming ipv4/ipv6-only is intended, since it'd
reject a DNS hostname.
- print_job.schema.json and print_params.schema.json were removed with
no replacement under the new structure - confirm that's intentional.
Also: the hand-written TypeBox schemas (ts-types) and serde structs
(rust-types) for Material/Template/PrintJob/PrintParameters now describe
the OLD flat shape and are out of sync with this restructure - per this
repo's hand-sync convention (see README), they need updating to match,
or the old ones should be removed if these new schemas supersede them.
Nothing imported the proto-generated code (zero references to
continuum-rust-types or @continuum/ts-types's proto export in any
consumer repo), and protobuf's real value - one schema compiled to a
compact binary wire format for both languages - never applied: BE<->proxy
and FE<->BE are both JSON. Also fixes 8 broken $ref paths in the bambulab
schemas and a root tsconfig.json that was sweeping the gitignored
examples/ directory into typecheck.
- proto/continuum/v1/*.proto -> schemas/continuum/v1/*.schema.json
- packages/rust-types: hand-written serde structs instead of
prost-build codegen
- packages/ts-types: drop the generated/ proto barrel export, keep the
hand-written TypeBox schemas as the TS source of truth
- root package.json: drop build:ts, protoc, ts-proto