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:
@@ -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}`);
|
||||
Reference in New Issue
Block a user