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
+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";