UNPKG

saxi

Version:

Drive the AxiDraw pen plotter

438 lines 17.5 kB
import { PenMotion, XYMotion } from "./planning.js"; import { vsub } from "./vec.js"; var MicrostepMode; (function (MicrostepMode) { MicrostepMode[MicrostepMode["DISABLED"] = 0] = "DISABLED"; MicrostepMode[MicrostepMode["SIXTEENTH"] = 1] = "SIXTEENTH"; MicrostepMode[MicrostepMode["EIGHTH"] = 2] = "EIGHTH"; MicrostepMode[MicrostepMode["QUARTER"] = 3] = "QUARTER"; MicrostepMode[MicrostepMode["HALF"] = 4] = "HALF"; MicrostepMode[MicrostepMode["FULL"] = 5] = "FULL"; })(MicrostepMode || (MicrostepMode = {})); /** Split d into its fractional and integral parts */ function modf(d) { const intPart = Math.floor(d); const fracPart = d - intPart; return [fracPart, intPart]; } export class EBB { port; commandQueue; writer; // biome-ignore lint/correctness/noUnusedPrivateClassMembers: used in constructor readableClosed; hardware; microsteppingMode = MicrostepMode.DISABLED; /** Accumulated XY error, used to correct for movements with sub-step resolution */ error = { x: 0, y: 0 }; cachedFirmwareVersion = undefined; constructor(port, hardware = "v3") { this.hardware = hardware; this.port = port; this.writer = this.port.writable.getWriter(); this.commandQueue = []; let buffer = ""; this.readableClosed = port.readable .pipeThrough(new TextDecoderStream()) .pipeTo(new WritableStream({ write: (chunk) => { buffer += chunk; const parts = buffer.split(/[\r\n]+/); // each command is on a different line buffer = parts.pop() || ""; for (const part of parts) { if (part.trim() === "") continue; // empty line if (this.commandQueue.length) { if (part[0] === "!") { // error from EBB this.commandQueue.shift()?.reject(new Error(part)); continue; } try { const d = this.commandQueue[0].next(part); if (d.done) { this.commandQueue.shift()?.resolve(d.value); } } catch (e) { this.commandQueue.shift()?.reject(e); } } else { console.log(`unexpected data: ${part}`); } } }, })) .catch((error) => { // Swallow premature close error; the disconnect handler takes care of it if (error.code !== "ERR_STREAM_PREMATURE_CLOSE") { throw error; } }); } get stepMultiplier() { switch (this.microsteppingMode) { case MicrostepMode.FULL: return 1; case MicrostepMode.HALF: return 2; case MicrostepMode.QUARTER: return 4; case MicrostepMode.EIGHTH: return 8; case MicrostepMode.SIXTEENTH: return 16; default: throw new Error(`Invalid microstepping mode: ${this.microsteppingMode}`); } // biome-ignore format: compactness } async close() { return await this.port.close(); } changeHardware(hardware) { this.hardware = hardware; } write(str) { if (process.env.DEBUG_SAXI_COMMANDS) { console.log(`writing: ${str}`); } const encoder = new TextEncoder(); return this.writer.write(encoder.encode(str)); } /** Send a raw command to the EBB and expect a single line in return, without an "OK" line to terminate. */ async query(cmd) { try { return await this.run(function* () { this.write(`${cmd}\r`); const result = yield; return result; }); } catch (err) { throw new Error(`Error in response to query '${cmd}': ${err.message}`); } } /** Send a raw command to the EBB and expect multiple lines in return, with an "OK" line to terminate. */ async queryM(cmd) { try { return await this.run(function* () { this.write(`${cmd}\r`); const result = []; while (true) { const line = yield; if (line === "OK") { break; } // biome-ignore format: compactness result.push(line); } return result; }); } catch (err) { throw new Error(`Error in response to queryM '${cmd}': ${err.message}`); } } /** Send a raw command to the EBB and expect a single "OK" line in return. */ async command(cmd) { try { return await this.run(function* () { this.write(`${cmd}\r`); const ok = yield; if (ok !== "OK") { throw new Error(`Expected OK, got ${ok}`); } }); } catch (err) { throw new Error(`Error in response to command '${cmd}': ${err.message}`); } } /** Reject all pending commands immediately **/ cancel() { while (this.commandQueue.length > 0) { this.commandQueue.shift()?.reject(new Error("Cancelled")); } } async enableMotors(microsteppingMode) { this.microsteppingMode = microsteppingMode; await this.command(`EM,${microsteppingMode},${microsteppingMode}`); // if the board supports SR, we should also enable the servo motors. if (await this.supportsSR()) await this.setServoPowerTimeout(0, true); } async disableMotors() { await this.command("EM,0,0"); // if the board supports SR, we should also disable the servo motors. if (await this.supportsSR()) // 60 seconds is the default boot-time servo power timeout. await this.setServoPowerTimeout(60000, false); } /** * Set the servo power timeout, in seconds. If a second parameter is * supplied, the servo will be immediately commanded into the given state (on * or off) depending on its value, in addition to setting the power-off * timeout duration. * * NB. this command is only available on firmware v2.6.0 and hardware of at * least version 2.5.0. */ async setServoPowerTimeout(timeout, power) { const timeoutMs = (timeout * 1000) | 0; if (power != null) { const powerState = power ? 1 : 0; await this.command(`SR,${timeoutMs},${powerState}`); } else { await this.command(`SR,${timeoutMs}`); } } // https://evil-mad.github.io/EggBot/ebb.html#S2 General RC Servo Output async setPenHeight(height, rate, delay = 0) { const output_pin = this.hardware === "v3" ? 4 : 5; return await this.command(`S2,${height},${output_pin},${rate},${delay}`); } lowlevelMove(stepsAxis1, initialStepsPerSecAxis1, finalStepsPerSecAxis1, stepsAxis2, initialStepsPerSecAxis2, finalStepsPerSecAxis2) { const [initialRate1, deltaR1] = this.axisRate(stepsAxis1, initialStepsPerSecAxis1, finalStepsPerSecAxis1); const [initialRate2, deltaR2] = this.axisRate(stepsAxis2, initialStepsPerSecAxis2, finalStepsPerSecAxis2); return this.command(`LM,${initialRate1},${stepsAxis1},${deltaR1},${initialRate2},${stepsAxis2},${deltaR2}`); } /** * Use the low-level move command "LM" to perform a constant-acceleration stepper move. * * Available with EBB firmware 2.5.3 and higher. * * @param xSteps Number of steps to move in the X direction * @param ySteps Number of steps to move in the Y direction * @param initialRate Initial step rate, in steps per second * @param finalRate Final step rate, in steps per second */ moveWithAcceleration(xSteps, ySteps, initialRate, finalRate) { if (!(xSteps !== 0 || ySteps !== 0)) { throw new Error("Must move on at least one axis"); } if (!(initialRate >= 0 && finalRate >= 0)) { throw new Error(`Rates must be positive, were ${initialRate},${finalRate}`); } if (!(initialRate > 0 || finalRate > 0)) { throw new Error("Must have non-zero velocity during motion"); } const stepsAxis1 = xSteps + ySteps; const stepsAxis2 = xSteps - ySteps; const norm = Math.sqrt(xSteps ** 2 + ySteps ** 2); const normX = xSteps / norm; const normY = ySteps / norm; const initialRateX = initialRate * normX; const initialRateY = initialRate * normY; const finalRateX = finalRate * normX; const finalRateY = finalRate * normY; const initialRateAxis1 = Math.abs(initialRateX + initialRateY); const initialRateAxis2 = Math.abs(initialRateX - initialRateY); const finalRateAxis1 = Math.abs(finalRateX + finalRateY); const finalRateAxis2 = Math.abs(finalRateX - finalRateY); return this.lowlevelMove(stepsAxis1, initialRateAxis1, finalRateAxis1, stepsAxis2, initialRateAxis2, finalRateAxis2); } /** * Use the high-level move command "XM" to perform a constant-velocity stepper move. * * @param duration Duration of the move, in seconds * @param x Number of microsteps to move in the X direction * @param y Number of microsteps to move in the Y direction */ moveAtConstantRate(duration, x, y) { return this.command(`XM,${Math.floor(duration * 1000)},${x},${y}`); } async waitUntilMotorsIdle() { // eslint-disable-next-line no-constant-condition while (true) { const [, commandStatus, _motor1Status, _motor2Status, fifoStatus] = (await this.query("QM")).split(","); if (commandStatus === "0" && fifoStatus === "0") { break; } } } async executeBlockWithLM(block) { const [errX, stepsX] = modf((block.p2.x - block.p1.x) * this.stepMultiplier + this.error.x); const [errY, stepsY] = modf((block.p2.y - block.p1.y) * this.stepMultiplier + this.error.y); this.error.x = errX; this.error.y = errY; if (stepsX !== 0 || stepsY !== 0) { await this.moveWithAcceleration(stepsX, stepsY, block.vInitial * this.stepMultiplier, block.vFinal * this.stepMultiplier); } } /** * Execute a constant-acceleration motion plan using the low-level LM command. * * Note that the LM command is only available starting from EBB firmware version 2.5.3. */ async executeXYMotionWithLM(plan) { for (const block of plan.blocks) { await this.executeBlockWithLM(block); } } /** * Execute a constant-acceleration motion plan using the high-level XM command. * * This is less accurate than using LM, since acceleration will only be adjusted every timestepMs milliseconds, * where LM can adjust the acceleration at a much higher rate, as it executes on-board the EBB. */ async executeXYMotionWithXM(plan, timestepMs = 15) { const timestepSec = timestepMs / 1000; let t = 0; while (t < plan.duration()) { const i1 = plan.instant(t); const i2 = plan.instant(t + timestepSec); const d = vsub(i2.p, i1.p); const [ex, sx] = modf(d.x * this.stepMultiplier + this.error.x); const [ey, sy] = modf(d.y * this.stepMultiplier + this.error.y); this.error.x = ex; this.error.y = ey; await this.moveAtConstantRate(timestepSec, sx, sy); t += timestepSec; } } /** Execute a constant-acceleration motion plan, starting and ending with zero velocity. */ async executeXYMotion(plan) { if (await this.supportsLM()) { await this.executeXYMotionWithLM(plan); } else { await this.executeXYMotionWithXM(plan); } } executePenMotion(pm) { // rate is in units of clocks per 24ms. // so to fit the entire motion in |pm.duration|, // dur = diff / rate // [time] = [clocks] / ([clocks]/[time]) // [time] = [clocks] * [clocks]^-1 * [time] // [time] = [time] // ✔ // so rate = diff / dur // dur is in [sec] // but rate needs to be in [clocks] / [24ms] // duration in units of 24ms is duration * [24ms] / [1s] const diff = Math.abs(pm.finalPos - pm.initialPos); const durMs = pm.duration() * 1000; const rate = Math.round((diff * 24) / durMs); return this.setPenHeight(pm.finalPos, rate, durMs); } executeMotion(m) { if (m instanceof XYMotion) { return this.executeXYMotion(m); } if (m instanceof PenMotion) { return this.executePenMotion(m); } throw new Error(`Unknown motion type: ${m.constructor.name}`); } async executePlan(plan, microsteppingMode = MicrostepMode.EIGHTH) { await this.enableMotors(microsteppingMode); for (const m of plan.motions) { await this.executeMotion(m); } await this.waitUntilMotorsIdle(); await this.disableMotors(); } /** * Query voltages for board & steppers. Useful to check whether stepper power is plugged in. * * @return Tuple of (RA0_VOLTAGE, V+_VOLTAGE, VIN_VOLTAGE) */ async queryVoltages() { const [ra0Voltage, vPlusVoltage] = (await this.queryM("QC"))[0].split(/,/).map(Number); return [ ra0Voltage / 1023.0 * 3.3, vPlusVoltage / 1023.0 * 3.3, vPlusVoltage / 1023.0 * 3.3 * 9.2 + 0.3 ]; // biome-ignore format: readability } /** * Query the firmware version running on the EBB. * * @return The version string, e.g. "EBBv13_and_above EB Firmware Version 2.5.3" */ async firmwareVersion() { return await this.query("V"); } /** * @return The firmware version as a parsed version triple, e.g. [2, 5, 3] */ async firmwareVersionNumber() { if (this.cachedFirmwareVersion === undefined) { const versionString = await this.firmwareVersion(); const versionWords = versionString.split(" "); const [major, minor, patch] = versionWords[versionWords.length - 1].split(".").map(Number); this.cachedFirmwareVersion = [major, minor, patch]; } return this.cachedFirmwareVersion; } /** * Compare the firmware version of the EBB with the given version. * * @return -1 if the firmware is older than the given version, 0 if it's * identical, and 1 if it's newer. */ async firmwareVersionCompare(major, minor, patch) { const [fwMajor, fwMinor, fwPatch] = await this.firmwareVersionNumber(); if (fwMajor < major) return -1; if (fwMajor > major) return 1; if (fwMinor < minor) return -1; if (fwMinor > minor) return 1; if (fwPatch < patch) return -1; if (fwPatch > patch) return 1; return 0; } async areSteppersPowered() { const [, , vInVoltage] = await this.queryVoltages(); return vInVoltage > 6; } async queryButton() { return (await this.queryM("QB"))[0] === "1"; } /** * @return true iff the EBB firmware supports the LM command. */ async supportsLM() { return (await this.firmwareVersionCompare(2, 5, 3)) >= 0; } /** * @return true iff the EBB firmware supports the SR command. */ async supportsSR() { return (await this.firmwareVersionCompare(2, 6, 0)) >= 0; } /** * Helper method for computing axis rates for the LM command. * * See http://evil-mad.github.io/EggBot/ebb.html#LM * * @param steps Number of steps being taken * @param initialStepsPerSec Initial movement rate, in steps per second * @param finalStepsPerSec Final movement rate, in steps per second * @return A tuple of (initialAxisRate, deltaR) that can be passed to the LM command */ axisRate(steps, initialStepsPerSec, finalStepsPerSec) { if (steps === 0) return [0, 0]; const initialRate = Math.round(initialStepsPerSec * (0x80000000 / 25000)); const finalRate = Math.round(finalStepsPerSec * (0x80000000 / 25000)); const moveTime = (2 * Math.abs(steps)) / (initialStepsPerSec + finalStepsPerSec); const deltaR = Math.round((finalRate - initialRate) / (moveTime * 25000)); return [initialRate, deltaR]; } run(g) { const cmd = g.call(this); const d = cmd.next(); if (d.done) { return Promise.resolve(d.value); } this.commandQueue.push(cmd); return new Promise((resolve, reject) => { cmd.resolve = resolve; cmd.reject = reject; }); } } //# sourceMappingURL=ebb.js.map