Close the printer command variants

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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 22:53:45 +00:00
co-authored by Claude Opus 5
parent 9b57c22fff
commit a8552047c3
2 changed files with 61 additions and 14 deletions
+21 -4
View File
@@ -74,6 +74,23 @@ export type PrinterTelemetry = Static<typeof PrinterTelemetrySchema>;
* JSON Schema for why that variant is absent until the gateway's * 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. * start-an-uploaded-file path has been run against real hardware by hand.
*/ */
/**
* Every variant is closed (`additionalProperties: false`), unlike
* `PrinterTelemetrySchema` which is deliberately open. The asymmetry is the
* point, and it follows from who writes each message.
*
* Telemetry is written by gateways in the field, so it is read liberally: an
* unknown field must not cost a whole farm's batch, and a gateway that is
* newer than the backend should still be understood.
*
* Commands are written by this backend, are a handful of fields, and move
* physical machines. Here an unrecognised field is a bug on the sending side,
* and silently dropping it is the worst available outcome - a caller that
* misspells a field would watch its command succeed and do something other
* than what it asked for. `ams_mapping` is the concrete case: it is
* deliberately not part of `start_print`, and a caller that passes one must
* be told, not ignored.
*/
export const PrinterCommandSchema = Type.Union([ export const PrinterCommandSchema = Type.Union([
Type.Object({ Type.Object({
action: Type.Literal("start_print"), action: Type.Literal("start_print"),
@@ -98,7 +115,7 @@ export const PrinterCommandSchema = Type.Union([
* may wait days and the URL would be long expired. * may wait days and the URL would be long expired.
*/ */
url: Type.String({ format: "uri" }), url: Type.String({ format: "uri" }),
}), }, { additionalProperties: false }),
Type.Object({ Type.Object({
action: Type.Literal("set_fan_speed"), action: Type.Literal("set_fan_speed"),
/** /**
@@ -107,7 +124,7 @@ export const PrinterCommandSchema = Type.Union([
* is set here does not read back identically. * is set here does not read back identically.
*/ */
percent: Type.Integer({ minimum: 0, maximum: 100 }), percent: Type.Integer({ minimum: 0, maximum: 100 }),
}), }, { additionalProperties: false }),
Type.Object({ Type.Object({
action: Type.Literal("control_print"), action: Type.Literal("control_print"),
/** /**
@@ -124,12 +141,12 @@ export const PrinterCommandSchema = Type.Union([
Type.Literal("resume"), Type.Literal("resume"),
Type.Literal("stop"), Type.Literal("stop"),
]), ]),
}), }, { additionalProperties: false }),
Type.Object({ Type.Object({
action: Type.Literal("send_gcode_line"), action: Type.Literal("send_gcode_line"),
/** The most powerful variant by far; be careful about exposing it to a UI. */ /** The most powerful variant by far; be careful about exposing it to a UI. */
line: Type.String(), line: Type.String(),
}), }, { additionalProperties: false }),
]); ]);
export type PrinterCommand = Static<typeof PrinterCommandSchema>; export type PrinterCommand = Static<typeof PrinterCommandSchema>;
@@ -2,14 +2,16 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/edge/printer_command.schema.json",
"title": "PrinterCommand", "title": "PrinterCommand",
"description": "The closed set of things the cloud may ask a printer to do. Deliberately small. It now includes start_print, which was held back until there was a job path to drive it; the thing to understand about that variant is that its acceptance says almost nothing - see its own description.", "description": "The closed set of things the cloud may ask a printer to do. Deliberately small. It now includes start_print, which was held back until there was a job path to drive it; the thing to understand about that variant is that its acceptance says almost nothing - see its own description. Every variant is closed (additionalProperties: false), unlike printer_telemetry which is deliberately open: telemetry is written by gateways in the field and read liberally, while a command is written by the backend, is a handful of fields, and moves a physical machine - an unrecognised field there is a bug on the sending side, and silently dropping it would let a caller misspell a field and watch its command succeed while doing something else.",
"oneOf": [ "oneOf": [
{ {
"type": "object", "type": "object",
"title": "StartPrint", "title": "StartPrint",
"description": "Print a job's file. The gateway downloads it from `url`, transfers it to the printer, and starts it. Three things about this variant that a caller must not get wrong. (1) Its result means the gateway accepted the job, not that anything is printing: a real end-to-end run on a P1P (2026-10-15) was accepted, uploaded byte-perfect, reached RUNNING, and then the printer paused itself on a physical filament fault. Job state has to be driven by the telemetry that follows, never by this reply. (2) There is no filament or AMS field on purpose. The gateway reads the mapping out of the project archive's own Metadata/slice_info.config, because the archive is the only thing that knows what each plate was sliced for - guessing it instead is what made that same printer heat to 250C and pause itself at 1%. (3) `url` is short-lived and must be presigned at dispatch time, not at job creation: a queued job may wait days, and a URL minted when it was queued would be expired by the time anything sent it.", "description": "Print a job's file. The gateway downloads it from `url`, transfers it to the printer, and starts it. Three things about this variant that a caller must not get wrong. (1) Its result means the gateway accepted the job, not that anything is printing: a real end-to-end run on a P1P (2026-10-15) was accepted, uploaded byte-perfect, reached RUNNING, and then the printer paused itself on a physical filament fault. Job state has to be driven by the telemetry that follows, never by this reply. (2) There is no filament or AMS field on purpose. The gateway reads the mapping out of the project archive's own Metadata/slice_info.config, because the archive is the only thing that knows what each plate was sliced for - guessing it instead is what made that same printer heat to 250C and pause itself at 1%. (3) `url` is short-lived and must be presigned at dispatch time, not at job creation: a queued job may wait days, and a URL minted when it was queued would be expired by the time anything sent it.",
"properties": { "properties": {
"action": { "const": "start_print" }, "action": {
"const": "start_print"
},
"job_id": { "job_id": {
"type": "string", "type": "string",
"format": "uuid", "format": "uuid",
@@ -27,13 +29,21 @@
"description": "A presigned, expiring GET for the job file. Deliberately a URL rather than the bytes: the file is tens of megabytes, this is a JSON control channel, and handing the gateway a URL keeps object-store credentials out of every gateway on every farm. Expect it to be valid for minutes, and note that an upload to a printer is slow - 0.2 MiB/s measured over FTPS to a P1P - so the download must finish before the transfer starts, not stream through it." "description": "A presigned, expiring GET for the job file. Deliberately a URL rather than the bytes: the file is tens of megabytes, this is a JSON control channel, and handing the gateway a URL keeps object-store credentials out of every gateway on every farm. Expect it to be valid for minutes, and note that an upload to a printer is slow - 0.2 MiB/s measured over FTPS to a P1P - so the download must finish before the transfer starts, not stream through it."
} }
}, },
"required": ["action", "job_id", "file_name", "url"] "required": [
"action",
"job_id",
"file_name",
"url"
],
"additionalProperties": false
}, },
{ {
"type": "object", "type": "object",
"title": "SetFanSpeed", "title": "SetFanSpeed",
"properties": { "properties": {
"action": { "const": "set_fan_speed" }, "action": {
"const": "set_fan_speed"
},
"percent": { "percent": {
"type": "integer", "type": "integer",
"minimum": 0, "minimum": 0,
@@ -41,32 +51,52 @@
"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." "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"] "required": [
"action",
"percent"
],
"additionalProperties": false
}, },
{ {
"type": "object", "type": "object",
"title": "ControlPrint", "title": "ControlPrint",
"description": "Pause, resume or stop the print the printer is currently running. Confirmed live on a P1P (2026-10-11): all three are accepted and a stop during PREPARE/RUNNING moved the printer to FAILED, which is how Bambu reports an operator-cancelled print. Note what the printer's acceptance does not mean - all three answered success while it was IDLE with nothing to pause, so a caller that needs to know the state actually changed must read telemetry back rather than trust the command result.", "description": "Pause, resume or stop the print the printer is currently running. Confirmed live on a P1P (2026-10-11): all three are accepted and a stop during PREPARE/RUNNING moved the printer to FAILED, which is how Bambu reports an operator-cancelled print. Note what the printer's acceptance does not mean - all three answered success while it was IDLE with nothing to pause, so a caller that needs to know the state actually changed must read telemetry back rather than trust the command result.",
"properties": { "properties": {
"action": { "const": "control_print" }, "action": {
"const": "control_print"
},
"control": { "control": {
"enum": ["pause", "resume", "stop"], "enum": [
"pause",
"resume",
"stop"
],
"description": "Stop is a cancel, not a pause: it ends the print and it cannot be undone. A UI should treat it accordingly." "description": "Stop is a cancel, not a pause: it ends the print and it cannot be undone. A UI should treat it accordingly."
} }
}, },
"required": ["action", "control"] "required": [
"action",
"control"
],
"additionalProperties": false
}, },
{ {
"type": "object", "type": "object",
"title": "SendGcodeLine", "title": "SendGcodeLine",
"properties": { "properties": {
"action": { "const": "send_gcode_line" }, "action": {
"const": "send_gcode_line"
},
"line": { "line": {
"type": "string", "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." "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"] "required": [
"action",
"line"
],
"additionalProperties": false
} }
] ]
} }