saxi
Version:
Drive the AxiDraw pen plotter
369 lines • 13.3 kB
JavaScript
/**
* 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