// 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 [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 [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}`);