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