Define the /ws/edge/v1 gateway uplink protocol

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.
This commit is contained in:
2026-10-03 10:37:13 +00:00
parent ef6c422458
commit bb5a2680af
11 changed files with 774 additions and 4 deletions
@@ -0,0 +1,144 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/edge/client_message.schema.json",
"title": "EdgeClientMessage",
"description": "What a continuum-proxy gateway sends up to continuum-backend's /ws/edge/v1 route. Discriminated on 'type'. Field names are snake_case, matching every other schema in this repository - note that this differs from the camelCase the backend's HTTP routes use, because this wire format is shared with a Rust consumer whose serde structs derive these names directly.",
"oneOf": [
{
"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.",
"properties": {
"type": {
"const": "hello"
},
"gateway_id": {
"type": "string"
},
"farm_id": {
"type": "string"
},
"token": {
"type": "string"
}
},
"required": [
"type",
"gateway_id",
"farm_id",
"token"
]
},
{
"type": "object",
"title": "Heartbeat",
"properties": {
"type": {
"const": "heartbeat"
},
"seq": {
"type": "integer",
"minimum": 0,
"description": "Monotonic per connection, restarting at 1 on reconnect. Echoed back in heartbeat_ack so the gateway can tell a stalled connection from a slow one."
}
},
"required": [
"type",
"seq"
]
},
{
"type": "object",
"title": "Telemetry",
"description": "One snapshot per printer that has something to report, on a timer. Sent as a batch rather than a message per printer: a farm has one uplink and many printers, and the backend wants a coherent picture of the farm at an instant more than it wants the lowest possible latency per machine.",
"properties": {
"type": {
"const": "telemetry"
},
"printers": {
"type": "array",
"items": {
"$ref": "./printer_telemetry.schema.json"
}
}
},
"required": [
"type",
"printers"
]
},
{
"type": "object",
"title": "CommandResult",
"description": "The reply to a server 'command'. Correlated by command_id, which the backend generates - unlike Bambu's sequence_id, Continuum owns both ends of this protocol, so the side that needs the correlation gets to choose it.",
"properties": {
"type": {
"const": "command_result"
},
"command_id": {
"type": "string"
},
"ok": {
"type": "boolean"
},
"error": {
"type": [
"string",
"null"
],
"description": "Present only when ok is false. A human-readable message, not a code: its only destination is an operator looking at why a command did not take. 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."
}
},
"required": [
"type",
"command_id",
"ok"
]
},
{
"type": "object",
"title": "FrameBatch",
"description": "Camera stills already uploaded to object storage, handed over for the AI worker to score. Not yet sent by any gateway - continuum-proxy restreams cameras through go2rtc and has no frame-capture path yet - but the backend consumes it, so it is part of the contract.",
"properties": {
"type": {
"const": "frame_batch"
},
"printer_id": {
"type": "string"
},
"print_job_id": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "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."
},
"frames": {
"type": "array",
"items": {
"type": "object",
"properties": {
"object_key": {
"type": "string"
},
"captured_at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"object_key",
"captured_at"
]
}
}
},
"required": [
"type",
"printer_id",
"frames"
]
}
]
}
@@ -0,0 +1,34 @@
{
"$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. The gateway can start an uploaded file, but that code path has never been run against real hardware, and the first thing to exercise it should be a human watching the machine rather than a message arriving from the internet. Add the variant here once it has been confirmed by hand.",
"oneOf": [
{
"type": "object",
"title": "SetFanSpeed",
"properties": {
"action": { "const": "set_fan_speed" },
"percent": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"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"]
},
{
"type": "object",
"title": "SendGcodeLine",
"properties": {
"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"]
}
]
}
@@ -0,0 +1,110 @@
{
"$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_telemetry.schema.json",
"title": "PrinterTelemetry",
"description": "A flattened snapshot of one printer, in the terms an operator console displays. Deliberately not any vendor's native status shape: it has to describe a Bambu, a Prusa and a Klipper printer alike, and Bambu's wire format in particular has traps that should not reach the rest of the system (fan speeds are 0-15 gears rather than a percent, temperatures arrive as either integers or floats, and protocol v2 reuses one sequence_id indefinitely). Every field but printer_id and name is optional, because a printer that is unreachable or has only just connected genuinely has nothing to report, and 'no data yet' is not the same as zero.",
"type": "object",
"properties": {
"printer_id": {
"type": "string",
"description": "The gateway's own id for this printer, from its printers.toml - NOT the vendor serial number and NOT the backend's printers.id UUID. The backend resolves it to a UUID via printers.gateway_printer_id, scoped to the farm the gateway authenticated as."
},
"name": {
"type": "string"
},
"state": {
"type": [
"string",
"null"
],
"description": "The printer's own state string, passed through rather than narrowed to an enum here, so an unrecognised state reaches the backend as itself instead of being dropped at the edge. Bambu emits IDLE, RUNNING, PAUSE, FINISH and FAILED. 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."
},
"percent": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 100,
"description": "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."
},
"layer": {
"type": [
"integer",
"null"
],
"minimum": 0,
"description": "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."
},
"total_layers": {
"type": [
"integer",
"null"
],
"minimum": 0,
"description": "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."
},
"remaining_minutes": {
"type": [
"integer",
"null"
],
"minimum": 0,
"description": "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."
},
"job_name": {
"type": [
"string",
"null"
],
"description": "Normalised at the edge: a Bambu reports an empty string rather than omitting the field when idle, and the gateway turns that into an absent value so consumers need not know it. 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."
},
"nozzle_temp": {
"type": [
"number",
"null"
],
"description": "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."
},
"nozzle_target": {
"type": [
"number",
"null"
],
"description": "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."
},
"bed_temp": {
"type": [
"number",
"null"
],
"description": "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."
},
"bed_target": {
"type": [
"number",
"null"
],
"description": "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."
},
"print_error": {
"type": [
"integer",
"null"
],
"description": "Vendor error code. Non-zero means the printer is reporting a fault. 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."
},
"active_alerts": {
"type": [
"integer",
"null"
],
"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."
}
},
"required": [
"printer_id",
"name"
]
}
@@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/edge/server_message.schema.json",
"title": "EdgeServerMessage",
"description": "What continuum-backend sends back down the same WebSocket. Discriminated on 'type'; snake_case for the same reason as EdgeClientMessage.",
"oneOf": [
{
"type": "object",
"title": "HelloAck",
"properties": {
"type": { "const": "hello_ack" },
"session_id": { "type": "string" }
},
"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" }
},
"required": ["type", "message"]
},
{
"type": "object",
"title": "HeartbeatAck",
"properties": {
"type": { "const": "heartbeat_ack" },
"seq": {
"type": "integer",
"minimum": 0,
"description": "Echoes the seq from the heartbeat being acknowledged."
}
},
"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" },
"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" }
},
"required": ["type", "command_id", "printer_id", "command"]
}
]
}