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.
`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.
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