diff --git a/src/routes/printers.ts b/src/routes/printers.ts index e49953f..31415ff 100644 --- a/src/routes/printers.ts +++ b/src/routes/printers.ts @@ -131,6 +131,14 @@ export const printerRoutes = new Elysia({ prefix: "/printers" }) * physical machine, and the blast radius of getting this boundary wrong is * a ruined print or a hot end driven into a plate. It is the one role * boundary here that is not about data. + * + * As of 2026-10-11 the set includes `control_print`, whose `stop` is a + * cancel: confirmed on a real P1P, it ends the running print and the + * printer then reports `FAILED`. There is no undo, so a UI must not put it + * where `pause` is expected. Note also that a gateway returning `ok` means + * the printer *accepted* the command - on an idle P1P all three verbs + * answer success - so whether the state changed has to be read back from + * telemetry. */ .post( "/:id/commands", @@ -166,8 +174,10 @@ export const printerRoutes = new Elysia({ prefix: "/printers" }) }, // The same command set the gateway accepts, straight from the shared // schema - so this route cannot drift into offering something no gateway - // can execute. Note what is absent: there is no "start a print" command - // yet, deliberately. + // can execute. Note what is absent: there is still no "start a print" + // command, though no longer because the path is unproven (it was + // confirmed on a real P1P on 2026-10-11) - nothing uploads a sliced file + // to a gateway, and nothing decides which printer runs which job. { params: IdParams, body: PrinterCommandSchema, detail: { tags: ["printers"] } }, ) .get( diff --git a/tests/edge_protocol.test.ts b/tests/edge_protocol.test.ts index f0d59df..e1050db 100644 --- a/tests/edge_protocol.test.ts +++ b/tests/edge_protocol.test.ts @@ -87,6 +87,24 @@ describe("backend -> gateway frames", () => { printer_id: "p1p", command: { action: "send_gcode_line", line: "M106 S0" }, }, + { + type: "command", + command_id: "9a7d2f12-0000-4000-8000-000000000000", + printer_id: "p1p", + command: { action: "control_print", control: "pause" }, + }, + { + type: "command", + command_id: "9a7d2f13-0000-4000-8000-000000000000", + printer_id: "p1p", + command: { action: "control_print", control: "resume" }, + }, + { + type: "command", + command_id: "9a7d2f14-0000-4000-8000-000000000000", + printer_id: "p1p", + command: { action: "control_print", control: "stop" }, + }, ]; for (const frame of outbound) { @@ -95,10 +113,29 @@ describe("backend -> gateway frames", () => { }); } + it("rejects a print control verb the gateway does not implement", () => { + // "cancel" is the trap: it is what Moonraker calls this, and the Klipper + // implementation behind `control_print` really does send a cancel - but + // the wire word is "stop". A verb that only one vendor recognises must + // not validate here, or it reaches a gateway that cannot map it. + for (const control of ["cancel", "abort", "Pause", ""]) { + expect( + Value.Check(EdgeServerMessageSchema, { + type: "command", + command_id: "x", + printer_id: "p1p", + command: { action: "control_print", control }, + }), + ).toBe(false); + } + }); + it("has no start-print command to send", () => { - // Deliberate: the gateway can start an uploaded file, but that path has - // never been run against real hardware. Delete this test when it has been, - // and add the variant to the schema. + // Still deliberate, but no longer for the original reason: starting an + // uploaded file *has* now been confirmed on a real P1P (2026-10-11). + // What is missing is the path around it - nothing uploads a sliced file + // to a gateway and nothing decides which printer runs which job. Delete + // this test when there is something to start. expect( Value.Check(EdgeServerMessageSchema, { type: "command",