Add Klipper, OctoPrint and PrusaLink printers from the API

POST /farms/:id/printers now takes any of the four vendors the gateway can
drive. Credentials depend on the vendor: Bambu needs an access code and
serial, OctoPrint and PrusaLink an API key, Klipper nothing. A missing one
is a 422 naming what is needed. The API key is relayed to the gateway and
kept out of logs and errors like the access code.

The printer_vendor enum gains octoprint (migration 0006); PrusaLink is
stored as prusa, the name the enum already had.

scripts/e2e-vendors.sh adds each kind through the API against a fake REST
printer (scripts/fake-http-printer.ts) and runs a job through the real
scheduler to completion. The fake shares its author's reading of the docs
with the driver, so this proves the plumbing, not the protocols.
This commit is contained in:
2026-10-30 07:49:31 +00:00
parent 69b6efea9b
commit d53c22b641
9 changed files with 1171 additions and 12 deletions
+103
View File
@@ -0,0 +1,103 @@
// A stand-in for the REST printers (Klipper's Moonraker, OctoPrint, PrusaLink)
// so the real proxy can be driven end to end without owning one. It is only as
// good as my reading of those APIs - it proves the plumbing, not the hardware.
//
// bun scripts/fake-http-printer.ts <klipper|octoprint|prusa_link> <port> [apiKey]
//
// A file uploaded with "print" set starts a fake print that runs for RUN_S
// seconds (default 8) and then finishes. Every request is logged to stdout.
const [kind, portArg, apiKey = "e2e-key"] = process.argv.slice(2);
if (!["klipper", "octoprint", "prusa_link"].includes(kind ?? "")) {
console.error("usage: fake-http-printer.ts <klipper|octoprint|prusa_link> <port> [apiKey]");
process.exit(2);
}
const RUN_S = Number(process.env.RUN_S ?? 8);
let file: string | undefined;
let startedAt: number | undefined;
let cancelled = false;
const progress = () => (startedAt === undefined ? 0 : cancelled ? 0 : Math.min(1, (Date.now() - startedAt) / (RUN_S * 1000)));
const phase = () => (startedAt === undefined ? "idle" : cancelled ? "cancelled" : progress() >= 1 ? "done" : "printing");
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
function startFrom(body: string, name?: string) {
file = name ?? /filename="([^"]+)"/.exec(body)?.[1] ?? "unknown";
startedAt = Date.now();
cancelled = false;
}
Bun.serve({
port: Number(portArg),
async fetch(req) {
const url = new URL(req.url);
const body = req.method === "GET" ? "" : new TextDecoder().decode(await req.arrayBuffer());
console.log(`${req.method} ${url.pathname} ${body.length}b`);
const route = `${req.method} ${url.pathname}`;
if (kind === "klipper") {
if (route === "GET /printer/info") return json({ result: { state: "ready" } });
if (route === "GET /printer/objects/query") {
const p = progress();
const state = { idle: "standby", printing: "printing", done: "complete", cancelled: "cancelled" }[phase()];
return json({
result: {
status: {
print_stats: { state, filename: file ?? "", print_duration: p * RUN_S, info: { current_layer: Math.ceil(p * 10), total_layer: 10 } },
virtual_sdcard: { progress: p },
extruder: { temperature: phase() === "printing" ? 210 : 24, target: phase() === "printing" ? 210 : 0 },
heater_bed: { temperature: phase() === "printing" ? 60 : 23, target: phase() === "printing" ? 60 : 0 },
},
},
});
}
if (route === "POST /server/files/upload") {
if (!/name="print"\r\n\r\ntrue/.test(body)) return json({ error: "print flag missing" }, 400);
startFrom(body);
return json({ print_started: true }, 201);
}
if (route === "POST /printer/print/cancel") { cancelled = true; return json({ result: "ok" }); }
if (route === "POST /printer/gcode/script") return json({ result: "ok" });
} else {
if (req.headers.get("x-api-key") !== apiKey) return json({ error: "bad key" }, 403);
const ph = phase();
if (kind === "octoprint") {
if (route === "GET /api/job") {
return json({
state: ph === "printing" ? "Printing" : "Operational",
job: { file: { name: file ?? null, display: file ?? null } },
progress: { completion: ph === "idle" || ph === "cancelled" ? null : progress() * 100, printTimeLeft: Math.round((1 - progress()) * RUN_S) },
});
}
if (route === "GET /api/printer") {
return json({ temperature: { tool0: { actual: ph === "printing" ? 210 : 24, target: ph === "printing" ? 210 : 0 }, bed: { actual: 60, target: ph === "printing" ? 60 : 0 } } });
}
if (route === "POST /api/files/local") {
if (!/name="print"\r\n\r\ntrue/.test(body)) return json({ error: "print flag missing" }, 400);
startFrom(body);
return json({ done: true }, 201);
}
if (route === "POST /api/job") { if (body.includes("cancel")) cancelled = true; return new Response(null, { status: 204 }); }
if (route === "POST /api/printer/command") return new Response(null, { status: 204 });
} else {
const state = { idle: "IDLE", printing: "PRINTING", done: "FINISHED", cancelled: "STOPPED" }[ph];
if (route === "GET /api/v1/status") {
return json({
printer: { state, temp_nozzle: ph === "printing" ? 215 : 25, target_nozzle: ph === "printing" ? 215 : 0, temp_bed: 60, target_bed: 0 },
...(ph === "printing" ? { job: { id: 1, progress: progress() * 100, time_remaining: Math.round((1 - progress()) * RUN_S) } } : {}),
});
}
if (route === "GET /api/v1/job") return json({ id: 1, file: { display_name: file } });
if (req.method === "PUT" && url.pathname.startsWith("/api/v1/files/local/")) {
if (req.headers.get("print-after-upload") !== "?1") return json({ error: "print flag missing" }, 400);
startFrom(body, decodeURIComponent(url.pathname.split("/").pop() ?? ""));
return new Response(null, { status: 201 });
}
if (route === "DELETE /api/v1/job/1") { cancelled = true; return new Response(null, { status: 204 }); }
}
}
return json({ error: `no route ${route}` }, 404);
},
});
console.log(`fake ${kind} listening on :${portArg}`);