UNPKG

saxi

Version:

Drive the AxiDraw pen plotter

369 lines 13.3 kB
/** * Backend web server for controlling the EBB. * Serve both the front end UI as static files - made with React, and backend * API for controlling the EBB. * Keep open web sockets to the front end for real-time updates. */ import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { autoDetect } from "@serialport/bindings-cpp"; import cors from "cors"; import express from "express"; import { WebSocketServer } from "ws"; import { EBB } from "./ebb.js"; import { PenMotion, Plan } from "./planning.js"; import { SerialPortSerialPort } from "./serialport-serialport.js"; import * as _self from "./server.js"; // use self-import for test mocking import { formatDuration } from "./util.js"; /** * Shorthand for getting the device info, either EBB or com port. * @param ebb * @param com * @returns */ const getDeviceInfo = (ebb, _com) => { // biome-ignore lint/suspicious/noExplicitAny: private member access const portPath = ebb?.port?._path ?? null; return { path: portPath, hardware: ebb?.hardware }; }; /** * Start the express server. * @param port * @param hardware * @param com * @param enableCors * @param maxPayloadSize * @returns */ export async function startServer(port, hardware = "v3", com = null, enableCors = false, maxPayloadSize = "200mb", svgIoApiKey = "") { const app = express(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); app.use("/", express.static(path.join(__dirname, "..", "ui"))); app.use(express.json({ limit: maxPayloadSize })); if (enableCors) { app.use(cors()); } // Web and Socket server const server = http.createServer(app); const wss = new WebSocketServer({ server }); let ebb; let clients = []; let unpaused = null; let signalUnpause = null; let motionIdx = null; let currentPlan = null; let plotting = false; let controller = null; wss.on("connection", (ws) => { clients.push(ws); ws.on("message", (message) => { const msg = JSON.parse(message.toString()); switch (msg.c) { case "ping": ws.send(JSON.stringify({ c: "pong" })); break; case "limp": if (ebb) { ebb.disableMotors(); } break; case "setPenHeight": if (ebb) { (async () => { if (await ebb.supportsSR()) { await ebb.setServoPowerTimeout(10000, true); } await ebb.setPenHeight(msg.p.height, msg.p.rate); })(); } break; case "changeHardware": ebb?.changeHardware(msg.p.hardware); broadcast({ c: "dev", p: getDeviceInfo(ebb, com) }); break; } }); // send starting params to clients ws.send(JSON.stringify({ c: "dev", p: getDeviceInfo(ebb, com) })); ws.send(JSON.stringify({ c: "svgio-enabled", p: svgIoApiKey !== "" })); ws.send(JSON.stringify({ c: "pause", p: { paused: !!unpaused } })); if (motionIdx != null) { ws.send(JSON.stringify({ c: "progress", p: { motionIdx } })); } if (currentPlan != null) { ws.send(JSON.stringify({ c: "plan", p: { plan: currentPlan } })); } ws.on("close", () => { clients = clients.filter((w) => w !== ws); }); }); /** * /plot POST endpoint. Receive a plan on the POST body, and execute it. */ app.post("/plot", async (req, res) => { if (plotting) { console.log("Received plot request, but a plot is already in progress!"); res.status(400).send("Plot in progress"); return; } plotting = true; controller = new AbortController(); const { signal } = controller; try { const plan = Plan.deserialize(req.body); currentPlan = req.body; console.log(`Received plan of estimated duration ${formatDuration(plan.duration())}`); console.log(ebb != null ? "Beginning plot..." : "Simulating plot..."); res.status(200).end(); const begin = Date.now(); let wakeLock = null; // The wake-lock module is macOS-only. if (process.platform === "darwin") { try { // Dynamically import wake-lock only on macOS const { WakeLock } = await import("wake-lock"); wakeLock = new WakeLock("saxi plotting"); } catch (_error) { console.warn("Couldn't acquire wake lock. Ensure your machine does not sleep during plotting"); } } else { console.log("Wake lock not available on this platform. Ensure your machine does not sleep during plotting"); } try { await doPlot(ebb != null ? realPlotter : simPlotter, plan, signal); const end = Date.now(); console.log(`Plot took ${formatDuration((end - begin) / 1000)}`); } finally { if (wakeLock) { wakeLock.release(); } } } finally { plotting = false; controller = null; } }); app.get("/plot/status", (_req, res) => { res.json({ plotting }); }); app.post("/cancel", (_req, res) => { if (controller) { controller.abort(); controller = null; } ebb?.cancel(); if (unpaused) { signalUnpause(); broadcast({ c: "pause", p: { paused: false } }); } unpaused = signalUnpause = null; res.status(200).end(); }); app.post("/pause", (_req, res) => { if (!unpaused) { unpaused = new Promise((resolve) => { signalUnpause = resolve; }); broadcast({ c: "pause", p: { paused: true } }); } res.status(200).end(); }); app.post("/resume", (_req, res) => { if (signalUnpause) { signalUnpause(); signalUnpause = unpaused = null; } res.status(200).end(); }); app.post("/generate", async (req, res) => { if (plotting) { console.log("Received generate request, but a plot is already in progress!"); res.status(400).end("Plot in progress"); return; } const { prompt, vecType } = req.body; try { // call the api and return the svg const apiResp = await fetch("https://api.svg.io/v1/generate-image", { method: "post", headers: { Authorization: `Bearer ${svgIoApiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ prompt, style: vecType, negativePrompt: "" }), }); // forward the api response const data = await apiResp.json(); res.status(apiResp.status).send(data); } catch (err) { console.error(err); res.status(500).end(); } }); function broadcast(msg) { for (const client of clients) { try { client.send(JSON.stringify(msg)); } catch (e) { console.warn(e); } } } const realPlotter = { async prePlot(initialPenHeight) { await ebb.enableMotors(1); // 16x microstepping, matches defaults from Axidraw await ebb.setPenHeight(initialPenHeight, 1000, 1000); }, async executeMotion(motion, _progress) { await ebb.executeMotion(motion); }, async postCancel(initialPenHeight) { await ebb.setPenHeight(initialPenHeight, 1000); await ebb.command("HM,4000"); // HM returns carriage home without 3rd and 4th arguments }, async postPlot() { await ebb.waitUntilMotorsIdle(); await ebb.disableMotors(); }, }; const simPlotter = { // eslint-disable-next-line @typescript-eslint/no-empty-function async prePlot(_initialPenHeight) { }, async executeMotion(motion, progress) { console.log(`Motion ${progress[0] + 1}/${progress[1]}`); await new Promise((resolve) => setTimeout(resolve, motion.duration() * 1000)); }, async postCancel(_initialPenHeight) { console.log("Plot cancelled"); }, // eslint-disable-next-line @typescript-eslint/no-empty-function async postPlot() { }, }; async function doPlot(plotter, plan, signal) { const abortPromise = onceAbort(signal); // reuse abort promise unpaused = null; signalUnpause = null; motionIdx = 0; const firstPenMotion = plan.motions.find((x) => x instanceof PenMotion); await plotter.prePlot(firstPenMotion.initialPos); let penIsUp = true; try { for (const motion of plan.motions) { broadcast({ c: "progress", p: { motionIdx } }); await Promise.race([plotter.executeMotion(motion, [motionIdx, plan.motions.length]), abortPromise]); if (motion instanceof PenMotion) { penIsUp = motion.initialPos < motion.finalPos; } if (unpaused && penIsUp) { await Promise.race([unpaused, abortPromise]); broadcast({ c: "pause", p: { paused: false } }); } motionIdx += 1; } broadcast({ c: "finished" }); } catch (err) { if (signal.aborted) { await plotter.postCancel(firstPenMotion.initialPos); broadcast({ c: "cancelled" }); return; } throw err; // propagate real errors } finally { motionIdx = null; currentPlan = null; await plotter.postPlot(); } } function onceAbort(signal) { return new Promise((_resolve, reject) => { signal.throwIfAborted(); signal.addEventListener("abort", () => reject(new Error("Aborted")), { once: true }); }); } return new Promise((resolve) => { server.listen(port, () => { async function connect() { const devices = ebbs(com, hardware); for await (const device of devices) { ebb = device; broadcast({ c: "dev", p: getDeviceInfo(ebb, com) }); } } connect(); const { family, address, port } = server.address(); const addr = `${family === "IPv6" ? `[${address}]` : address}:${port}`; console.log(`Server listening on http://${addr}`); resolve(server); }); }); } async function tryOpen(com) { const port = new SerialPortSerialPort(com); await port.open({ baudRate: 9600 }); return port; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function isEBB(p) { return (p.manufacturer === "SchmalzHaus" || p.manufacturer === "SchmalzHaus LLC" || (p.vendorId === "04D8" && p.productId === "FD92")); } async function listEBBs() { const Binding = autoDetect(); const ports = await Binding.list(); return ports.filter(isEBB).map((p) => p.path); } export async function waitForEbb() { // eslint-disable-next-line no-constant-condition while (true) { const ebbs = await listEBBs(); if (ebbs.length) { return ebbs[0]; } await sleep(5000); } } async function* ebbs(path, hardware = "v3") { while (true) { try { const com = path || (await _self.waitForEbb()); // use self-import for test mocking console.log(`Found EBB at ${com}`); const port = await tryOpen(com); const closed = new Promise((resolve) => { port.addEventListener("disconnect", resolve, { once: true }); }); yield new EBB(port, hardware); await closed; yield null; console.error("Lost connection to EBB, reconnecting..."); } catch (e) { console.error(`Error connecting to EBB: ${e.message}`); console.error("Retrying in 5 seconds..."); await sleep(5000); } } } export async function connectEBB(hardware, device) { let dev = device; if (!device) { const ebbs = await listEBBs(); if (ebbs.length === 0) return null; dev = ebbs[0]; } const port = await tryOpen(dev); return new EBB(port, hardware); } //# sourceMappingURL=server.js.map