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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:37:13 +00:00
co-authored by Claude Opus 5
parent 4a54ed62f7
commit ff736f0633
11 changed files with 774 additions and 4 deletions
+1 -1
View File
@@ -10,6 +10,6 @@
".": "./src/index.ts"
},
"dependencies": {
"@sinclair/typebox": "^0.32.0"
"@sinclair/typebox": "^0.33.12"
}
}
+152
View File
@@ -0,0 +1,152 @@
import { type Static } from "@sinclair/typebox";
/**
* The /ws/edge/v1 uplink protocol, mirroring
* `schemas/continuum/v1/edge/*.schema.json` by hand - the same convention
* every other schema in this package follows. The JSON Schema files are
* canonical; change them first, then this, then the Rust mirror (which for
* this one protocol lives in `continuum-proxy/src/uplink/protocol.rs` rather
* than in `packages/rust-types`, since the gateway is its only Rust
* consumer).
*
* Field names are snake_case, unlike the backend's HTTP routes. That is not
* an oversight: the other end of this wire is serde-derived Rust, and
* snake_case is what those structs produce without per-field rename
* attributes. It is also what the rest of this repository uses.
*/
export declare const PrinterTelemetrySchema: import("@sinclair/typebox").TObject<{
/**
* The *gateway's* own id from its printers.toml - not a vendor serial and
* not `printers.id`. The backend resolves it through
* `printers.gateway_printer_id`, scoped to the authenticated farm.
*/
printer_id: import("@sinclair/typebox").TString;
name: import("@sinclair/typebox").TString;
/**
* The printer's own state string rather than an enum, so an unrecognised
* state survives the trip instead of being dropped at the edge. Bambu
* emits IDLE, RUNNING, PAUSE, FINISH, FAILED.
*/
state: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TString, import("@sinclair/typebox").TNull]>>;
percent: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
layer: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
total_layers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
remaining_minutes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
job_name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TString, import("@sinclair/typebox").TNull]>>;
nozzle_temp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TNull]>>;
nozzle_target: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TNull]>>;
bed_temp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TNull]>>;
bed_target: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TNull]>>;
/** Vendor error code; non-zero means the printer is reporting a fault. */
print_error: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
/** Count of active Health-Management-System entries, not decoded messages. */
active_alerts: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
}>;
export type PrinterTelemetry = Static<typeof PrinterTelemetrySchema>;
/**
* Deliberately small, and deliberately missing "start a print" - see the
* 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.
*/
export declare const PrinterCommandSchema: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
action: import("@sinclair/typebox").TLiteral<"set_fan_speed">;
/**
* A percentage on this wire whatever the vendor wants. A Bambu turns it
* into an M106 PWM value and reports it back as one of 16 gears, so what
* is set here does not read back identically.
*/
percent: import("@sinclair/typebox").TInteger;
}>, import("@sinclair/typebox").TObject<{
action: import("@sinclair/typebox").TLiteral<"send_gcode_line">;
/** The most powerful variant by far; be careful about exposing it to a UI. */
line: import("@sinclair/typebox").TString;
}>]>;
export type PrinterCommand = Static<typeof PrinterCommandSchema>;
export declare const EdgeClientMessageSchema: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
type: import("@sinclair/typebox").TLiteral<"hello">;
gateway_id: import("@sinclair/typebox").TString;
farm_id: import("@sinclair/typebox").TString;
token: import("@sinclair/typebox").TString;
}>, import("@sinclair/typebox").TObject<{
type: import("@sinclair/typebox").TLiteral<"heartbeat">;
/** Monotonic per connection, restarting at 1 on reconnect. */
seq: import("@sinclair/typebox").TInteger;
}>, import("@sinclair/typebox").TObject<{
type: import("@sinclair/typebox").TLiteral<"telemetry">;
/**
* A batch, not a message per printer: a farm has one uplink and many
* printers, and a coherent picture of the farm at an instant is worth
* more here than the lowest possible per-machine latency.
*/
printers: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
/**
* The *gateway's* own id from its printers.toml - not a vendor serial and
* not `printers.id`. The backend resolves it through
* `printers.gateway_printer_id`, scoped to the authenticated farm.
*/
printer_id: import("@sinclair/typebox").TString;
name: import("@sinclair/typebox").TString;
/**
* The printer's own state string rather than an enum, so an unrecognised
* state survives the trip instead of being dropped at the edge. Bambu
* emits IDLE, RUNNING, PAUSE, FINISH, FAILED.
*/
state: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TString, import("@sinclair/typebox").TNull]>>;
percent: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
layer: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
total_layers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
remaining_minutes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
job_name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TString, import("@sinclair/typebox").TNull]>>;
nozzle_temp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TNull]>>;
nozzle_target: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TNull]>>;
bed_temp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TNull]>>;
bed_target: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TNull]>>;
/** Vendor error code; non-zero means the printer is reporting a fault. */
print_error: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
/** Count of active Health-Management-System entries, not decoded messages. */
active_alerts: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TNull]>>;
}>>;
}>, import("@sinclair/typebox").TObject<{
type: import("@sinclair/typebox").TLiteral<"command_result">;
command_id: import("@sinclair/typebox").TString;
ok: import("@sinclair/typebox").TBoolean;
/** Only when `ok` is false. A message for an operator, not a code. */
error: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TString, import("@sinclair/typebox").TNull]>>;
}>, import("@sinclair/typebox").TObject<{
type: import("@sinclair/typebox").TLiteral<"frame_batch">;
printer_id: import("@sinclair/typebox").TString;
print_job_id: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TString, import("@sinclair/typebox").TNull]>>;
frames: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
object_key: import("@sinclair/typebox").TString;
captured_at: import("@sinclair/typebox").TString;
}>>;
}>]>;
export type EdgeClientMessage = Static<typeof EdgeClientMessageSchema>;
export declare const EdgeServerMessageSchema: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
type: import("@sinclair/typebox").TLiteral<"hello_ack">;
session_id: import("@sinclair/typebox").TString;
}>, import("@sinclair/typebox").TObject<{
type: import("@sinclair/typebox").TLiteral<"hello_error">;
message: import("@sinclair/typebox").TString;
}>, import("@sinclair/typebox").TObject<{
type: import("@sinclair/typebox").TLiteral<"heartbeat_ack">;
seq: import("@sinclair/typebox").TInteger;
}>, import("@sinclair/typebox").TObject<{
type: import("@sinclair/typebox").TLiteral<"command">;
command_id: import("@sinclair/typebox").TString;
/** The gateway's own printer id - the backend translates on the way out. */
printer_id: import("@sinclair/typebox").TString;
command: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
action: import("@sinclair/typebox").TLiteral<"set_fan_speed">;
/**
* A percentage on this wire whatever the vendor wants. A Bambu turns it
* into an M106 PWM value and reports it back as one of 16 gears, so what
* is set here does not read back identically.
*/
percent: import("@sinclair/typebox").TInteger;
}>, import("@sinclair/typebox").TObject<{
action: import("@sinclair/typebox").TLiteral<"send_gcode_line">;
/** The most powerful variant by far; be careful about exposing it to a UI. */
line: import("@sinclair/typebox").TString;
}>]>;
}>]>;
export type EdgeServerMessage = Static<typeof EdgeServerMessageSchema>;
+125
View File
@@ -0,0 +1,125 @@
import { Type } from "@sinclair/typebox";
/**
* Optional *and* nullable: the key may be missing, or present as `null`.
*
* Strict in what we send, liberal in what we accept. A gateway omits fields
* it has no value for, which is what the JSON Schema describes - but a
* serde-derived Rust struct emits `null` by default unless every field is
* annotated, and one such field reaching here would otherwise fail
* validation and make Elysia drop an entire telemetry batch over a printer
* that merely had no job name. Not worth risking a farm's telemetry on.
*/
const Maybe = (schema) => Type.Optional(Type.Union([schema, Type.Null()]));
/**
* The /ws/edge/v1 uplink protocol, mirroring
* `schemas/continuum/v1/edge/*.schema.json` by hand - the same convention
* every other schema in this package follows. The JSON Schema files are
* canonical; change them first, then this, then the Rust mirror (which for
* this one protocol lives in `continuum-proxy/src/uplink/protocol.rs` rather
* than in `packages/rust-types`, since the gateway is its only Rust
* consumer).
*
* Field names are snake_case, unlike the backend's HTTP routes. That is not
* an oversight: the other end of this wire is serde-derived Rust, and
* snake_case is what those structs produce without per-field rename
* attributes. It is also what the rest of this repository uses.
*/
export const PrinterTelemetrySchema = Type.Object({
/**
* The *gateway's* own id from its printers.toml - not a vendor serial and
* not `printers.id`. The backend resolves it through
* `printers.gateway_printer_id`, scoped to the authenticated farm.
*/
printer_id: Type.String(),
name: Type.String(),
/**
* The printer's own state string rather than an enum, so an unrecognised
* state survives the trip instead of being dropped at the edge. Bambu
* emits IDLE, RUNNING, PAUSE, FINISH, FAILED.
*/
state: Maybe(Type.String()),
percent: Maybe(Type.Integer({ minimum: 0, maximum: 100 })),
layer: Maybe(Type.Integer({ minimum: 0 })),
total_layers: Maybe(Type.Integer({ minimum: 0 })),
remaining_minutes: Maybe(Type.Integer({ minimum: 0 })),
job_name: Maybe(Type.String()),
nozzle_temp: Maybe(Type.Number()),
nozzle_target: Maybe(Type.Number()),
bed_temp: Maybe(Type.Number()),
bed_target: Maybe(Type.Number()),
/** Vendor error code; non-zero means the printer is reporting a fault. */
print_error: Maybe(Type.Integer()),
/** Count of active Health-Management-System entries, not decoded messages. */
active_alerts: Maybe(Type.Integer({ minimum: 0 })),
});
/**
* Deliberately small, and deliberately missing "start a print" - see the
* 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.
*/
export const PrinterCommandSchema = Type.Union([
Type.Object({
action: Type.Literal("set_fan_speed"),
/**
* A percentage on this wire whatever the vendor wants. A Bambu turns it
* into an M106 PWM value and reports it back as one of 16 gears, so what
* is set here does not read back identically.
*/
percent: Type.Integer({ minimum: 0, maximum: 100 }),
}),
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(),
}),
]);
export const EdgeClientMessageSchema = Type.Union([
Type.Object({
type: Type.Literal("hello"),
gateway_id: Type.String(),
farm_id: Type.String(),
token: Type.String(),
}),
Type.Object({
type: Type.Literal("heartbeat"),
/** Monotonic per connection, restarting at 1 on reconnect. */
seq: Type.Integer({ minimum: 0 }),
}),
Type.Object({
type: Type.Literal("telemetry"),
/**
* A batch, not a message per printer: a farm has one uplink and many
* printers, and a coherent picture of the farm at an instant is worth
* more here than the lowest possible per-machine latency.
*/
printers: Type.Array(PrinterTelemetrySchema),
}),
Type.Object({
type: Type.Literal("command_result"),
command_id: Type.String(),
ok: Type.Boolean(),
/** Only when `ok` is false. A message for an operator, not a code. */
error: Maybe(Type.String()),
}),
Type.Object({
type: Type.Literal("frame_batch"),
printer_id: Type.String(),
print_job_id: Maybe(Type.String({ format: "uuid" })),
frames: Type.Array(Type.Object({
object_key: Type.String(),
captured_at: Type.String({ format: "date-time" }),
})),
}),
]);
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 }) }),
Type.Object({
type: Type.Literal("command"),
command_id: Type.String(),
/** The gateway's own printer id - the backend translates on the way out. */
printer_id: Type.String(),
command: PrinterCommandSchema,
}),
]);
+137
View File
@@ -0,0 +1,137 @@
import { Type, type Static, type TSchema } from "@sinclair/typebox";
/**
* Optional *and* nullable: the key may be missing, or present as `null`.
*
* Strict in what we send, liberal in what we accept. A gateway omits fields
* it has no value for, which is what the JSON Schema describes - but a
* serde-derived Rust struct emits `null` by default unless every field is
* annotated, and one such field reaching here would otherwise fail
* validation and make Elysia drop an entire telemetry batch over a printer
* that merely had no job name. Not worth risking a farm's telemetry on.
*/
const Maybe = <T extends TSchema>(schema: T) => Type.Optional(Type.Union([schema, Type.Null()]));
/**
* The /ws/edge/v1 uplink protocol, mirroring
* `schemas/continuum/v1/edge/*.schema.json` by hand - the same convention
* every other schema in this package follows. The JSON Schema files are
* canonical; change them first, then this, then the Rust mirror (which for
* this one protocol lives in `continuum-proxy/src/uplink/protocol.rs` rather
* than in `packages/rust-types`, since the gateway is its only Rust
* consumer).
*
* Field names are snake_case, unlike the backend's HTTP routes. That is not
* an oversight: the other end of this wire is serde-derived Rust, and
* snake_case is what those structs produce without per-field rename
* attributes. It is also what the rest of this repository uses.
*/
export const PrinterTelemetrySchema = Type.Object({
/**
* The *gateway's* own id from its printers.toml - not a vendor serial and
* not `printers.id`. The backend resolves it through
* `printers.gateway_printer_id`, scoped to the authenticated farm.
*/
printer_id: Type.String(),
name: Type.String(),
/**
* The printer's own state string rather than an enum, so an unrecognised
* state survives the trip instead of being dropped at the edge. Bambu
* emits IDLE, RUNNING, PAUSE, FINISH, FAILED.
*/
state: Maybe(Type.String()),
percent: Maybe(Type.Integer({ minimum: 0, maximum: 100 })),
layer: Maybe(Type.Integer({ minimum: 0 })),
total_layers: Maybe(Type.Integer({ minimum: 0 })),
remaining_minutes: Maybe(Type.Integer({ minimum: 0 })),
job_name: Maybe(Type.String()),
nozzle_temp: Maybe(Type.Number()),
nozzle_target: Maybe(Type.Number()),
bed_temp: Maybe(Type.Number()),
bed_target: Maybe(Type.Number()),
/** Vendor error code; non-zero means the printer is reporting a fault. */
print_error: Maybe(Type.Integer()),
/** Count of active Health-Management-System entries, not decoded messages. */
active_alerts: Maybe(Type.Integer({ minimum: 0 })),
});
export type PrinterTelemetry = Static<typeof PrinterTelemetrySchema>;
/**
* Deliberately small, and deliberately missing "start a print" - see the
* 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.
*/
export const PrinterCommandSchema = Type.Union([
Type.Object({
action: Type.Literal("set_fan_speed"),
/**
* A percentage on this wire whatever the vendor wants. A Bambu turns it
* into an M106 PWM value and reports it back as one of 16 gears, so what
* is set here does not read back identically.
*/
percent: Type.Integer({ minimum: 0, maximum: 100 }),
}),
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(),
}),
]);
export type PrinterCommand = Static<typeof PrinterCommandSchema>;
export const EdgeClientMessageSchema = Type.Union([
Type.Object({
type: Type.Literal("hello"),
gateway_id: Type.String(),
farm_id: Type.String(),
token: Type.String(),
}),
Type.Object({
type: Type.Literal("heartbeat"),
/** Monotonic per connection, restarting at 1 on reconnect. */
seq: Type.Integer({ minimum: 0 }),
}),
Type.Object({
type: Type.Literal("telemetry"),
/**
* A batch, not a message per printer: a farm has one uplink and many
* printers, and a coherent picture of the farm at an instant is worth
* more here than the lowest possible per-machine latency.
*/
printers: Type.Array(PrinterTelemetrySchema),
}),
Type.Object({
type: Type.Literal("command_result"),
command_id: Type.String(),
ok: Type.Boolean(),
/** Only when `ok` is false. A message for an operator, not a code. */
error: Maybe(Type.String()),
}),
Type.Object({
type: Type.Literal("frame_batch"),
printer_id: Type.String(),
print_job_id: Maybe(Type.String({ format: "uuid" })),
frames: Type.Array(
Type.Object({
object_key: Type.String(),
captured_at: Type.String({ format: "date-time" }),
}),
),
}),
]);
export type EdgeClientMessage = Static<typeof EdgeClientMessageSchema>;
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 }) }),
Type.Object({
type: Type.Literal("command"),
command_id: Type.String(),
/** The gateway's own printer id - the backend translates on the way out. */
printer_id: Type.String(),
command: PrinterCommandSchema,
}),
]);
export type EdgeServerMessage = Static<typeof EdgeServerMessageSchema>;
+1 -1
View File
@@ -1 +1 @@
export {};
export * from "./edge";
+10 -1
View File
@@ -1 +1,10 @@
export {};
// The old Material/PrintParameters/PrintJob/Template mirrors were removed
// (2026-09) when schemas/continuum/v1/ was restructured into
// material/printer/vendor - see that directory and its README. Add a
// hand-written TypeBox schema here per file under schemas/continuum/v1/
// once something actually consumes it (continuum-backend's validators,
// most likely), same convention as before.
// The /ws/edge/v1 gateway uplink protocol - the first thing to actually need
// a TypeBox mirror here, consumed by continuum-backend's edge WebSocket
// route. Mirrors schemas/continuum/v1/edge/.
export * from "./edge";
+5 -1
View File
@@ -4,4 +4,8 @@
// hand-written TypeBox schema here per file under schemas/continuum/v1/
// once something actually consumes it (continuum-backend's validators,
// most likely), same convention as before.
export {};
// The /ws/edge/v1 gateway uplink protocol - the first thing to actually need
// a TypeBox mirror here, consumed by continuum-backend's edge WebSocket
// route. Mirrors schemas/continuum/v1/edge/.
export * from "./edge";
@@ -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"]
}
]
}