| 1 | // EiBotBoard (EBB) firmware v3 serial protocol (port of plotink's
|
| 2 | // ebb3_serial.py and ebb3_motion.py).
|
| 3 | //
|
| 4 | // The EBB is driven in "future syntax" mode (CU,10,1): every command answers
|
| 5 | // with its own name (e.g. "SM"), and queries answer "NAME,data" (e.g.
|
| 6 | // "QS,1421,-429"). Each line ends in "\n".
|
| 7 | //
|
| 8 | // A transport is any object with async open(), close(), write(text),
|
| 9 | // readLine(timeoutMs) -> string ('' on timeout) and clearInput().
|
| 10 |
|
| 11 | export const MIN_FIRMWARE = '3.0.2';
|
| 12 | const READ_TIMEOUT_MS = 1000;
|
| 13 | const EMPTY_LINE_RETRIES = 25;
|
| 14 |
|
| 15 | /** Compare dotted version strings; returns -1, 0 or 1. */
|
| 16 | export function compareVersions(a, b) {
|
| 17 | const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
|
| 18 | const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
|
| 19 | for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
| 20 | const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
| 21 | if (d !== 0) return d < 0 ? -1 : 1;
|
| 22 | }
|
| 23 | return 0;
|
| 24 | }
|
| 25 |
|
| 26 | /** Name of a command as it is echoed back: one letter or two. */
|
| 27 | export function commandName(cmd) {
|
| 28 | if (cmd.length === 1 || cmd[1] === ',') return cmd[0];
|
| 29 | return cmd.slice(0, 2);
|
| 30 | }
|
| 31 |
|
| 32 | export class EBB {
|
| 33 | constructor() {
|
| 34 | this.transport = null; // Set while connected.
|
| 35 | this.version = null; // Firmware version string, e.g. "3.0.3"
|
| 36 | this.name = null; // Nickname tag, if any
|
| 37 | this.err = null; // First fatal error message, or null
|
| 38 | }
|
| 39 |
|
| 40 | get connected() {
|
| 41 | return this.transport != null;
|
| 42 | }
|
| 43 |
|
| 44 | recordError(message) {
|
| 45 | if (this.err == null) this.err = message;
|
| 46 | }
|
| 47 |
|
| 48 | /** Open the transport, verify it is an EBB >= 3.0.2, enter future syntax. */
|
| 49 | async connect(transport) {
|
| 50 | if (this.transport) return true;
|
| 51 | this.err = null;
|
| 52 | let versionLine = '';
|
| 53 | try {
|
| 54 | await transport.open();
|
| 55 | await transport.clearInput();
|
| 56 | for (let attempt = 0; attempt < 2 && !versionLine.includes('EBB'); attempt++) {
|
| 57 | await transport.write('v\r');
|
| 58 | versionLine = (await transport.readLine(READ_TIMEOUT_MS)).trim();
|
| 59 | }
|
| 60 | } catch (e) {
|
| 61 | this.recordError(`Error testing USB connection: ${e.message ?? e}`);
|
| 62 | }
|
| 63 | if (!versionLine.includes('EBB')) {
|
| 64 | this.recordError('Failed to connect via USB: device did not identify as an EBB.');
|
| 65 | await transport.close().catch(() => {});
|
| 66 | return false;
|
| 67 | }
|
| 68 | this.transport = transport;
|
| 69 | this.parseVersion(versionLine);
|
| 70 | if (this.version == null || compareVersions(this.version, MIN_FIRMWARE) < 0) {
|
| 71 | this.recordError(`Firmware version (${this.version}) not supported. ` +
|
| 72 | `Firmware ${MIN_FIRMWARE} or newer is required. Visit https://bantam.tools/ndfw to update.`);
|
| 73 | return false;
|
| 74 | }
|
| 75 | // Enter future syntax mode. The reply may come in either syntax; discard it.
|
| 76 | await transport.write('CU,10,1\r');
|
| 77 | await transport.readLine(READ_TIMEOUT_MS);
|
| 78 | await transport.clearInput();
|
| 79 | await this.queryNickname();
|
| 80 | return true;
|
| 81 | }
|
| 82 |
|
| 83 | parseVersion(line) {
|
| 84 | const parts = line.split('Firmware Version ');
|
| 85 | if (parts.length < 2) return;
|
| 86 | this.version = parts[1].trim();
|
| 87 | }
|
| 88 |
|
| 89 | async disconnect() {
|
| 90 | const t = this.transport;
|
| 91 | this.transport = null;
|
| 92 | if (t) await t.close().catch(() => {});
|
| 93 | }
|
| 94 |
|
| 95 | /** Read a response line, skipping up to 25 empty lines (as pyserial readline would). */
|
| 96 | async readResponse() {
|
| 97 | let response = (await this.transport.readLine(READ_TIMEOUT_MS)).trim();
|
| 98 | for (let n = 0; response.length === 0 && n < EMPTY_LINE_RETRIES; n++) {
|
| 99 | response = (await this.transport.readLine(READ_TIMEOUT_MS)).trim();
|
| 100 | }
|
| 101 | return response;
|
| 102 | }
|
| 103 |
|
| 104 | /** Send a command; returns true if the EBB acknowledged it. */
|
| 105 | async command(cmd) {
|
| 106 | if (!this.transport || this.err != null || cmd == null) return false;
|
| 107 | cmd = cmd.trim();
|
| 108 | const name = commandName(cmd);
|
| 109 | let response = '';
|
| 110 | try {
|
| 111 | await this.transport.write(`${cmd}\r`);
|
| 112 | response = await this.readResponse();
|
| 113 | if (!response.startsWith(name)) {
|
| 114 | this.recordError(response
|
| 115 | ? `Unexpected response from EBB. Command: ${cmd} Response: ${response}`
|
| 116 | : `EBB serial timeout after command: ${cmd}`);
|
| 117 | }
|
| 118 | } catch (e) {
|
| 119 | if (!['rb', 'r', 'bl'].includes(name.toLowerCase())) {
|
| 120 | this.recordError(`USB communication error after command: ${cmd}`);
|
| 121 | }
|
| 122 | }
|
| 123 | if (response.includes('Err:')) {
|
| 124 | this.recordError(`Error reported by EBB. Command: ${cmd} Response: ${response}`);
|
| 125 | }
|
| 126 | return this.err == null;
|
| 127 | }
|
| 128 |
|
| 129 | /** Send a query; returns the response with the "NAME," prefix stripped, or null. */
|
| 130 | async query(qry) {
|
| 131 | if (!this.transport || this.err != null || qry == null) return null;
|
| 132 | qry = qry.trim();
|
| 133 | const name = commandName(qry);
|
| 134 | let response = '';
|
| 135 | try {
|
| 136 | await this.transport.write(`${qry}\r`);
|
| 137 | response = await this.readResponse();
|
| 138 | } catch (e) {
|
| 139 | if (!['rb', 'r', 'bl'].includes(name.toLowerCase())) {
|
| 140 | this.recordError(`USB communication error after query: ${qry}`);
|
| 141 | return null;
|
| 142 | }
|
| 143 | }
|
| 144 | if (response.includes('Err:') || !response.startsWith(name)) {
|
| 145 | this.recordError(response
|
| 146 | ? `Unexpected response from EBB. Query: ${qry} Response: ${response}`
|
| 147 | : `EBB serial timeout after query: ${qry}`);
|
| 148 | return null;
|
| 149 | }
|
| 150 | let headerLen = name.length;
|
| 151 | if (response.length > headerLen && response[headerLen] === ',') headerLen += 1;
|
| 152 | return response.slice(headerLen);
|
| 153 | }
|
| 154 |
|
| 155 | /** QG status byte as an integer, or null. */
|
| 156 | async queryStatusByte() {
|
| 157 | if (!this.transport || this.err != null) return null;
|
| 158 | let response = '';
|
| 159 | try {
|
| 160 | await this.transport.write('QG\r');
|
| 161 | response = (await this.transport.readLine(READ_TIMEOUT_MS)).trim();
|
| 162 | if (!response.startsWith('QG')) {
|
| 163 | this.recordError(response
|
| 164 | ? `Unexpected response from EBB to QG query: ${response}`
|
| 165 | : 'EBB serial timeout while reading status byte.');
|
| 166 | }
|
| 167 | } catch (e) {
|
| 168 | this.recordError('USB communication error after status byte query');
|
| 169 | return null;
|
| 170 | }
|
| 171 | if (response.includes('Err:')) {
|
| 172 | this.recordError(`Error reported by EBB. Query: QG Response: ${response}`);
|
| 173 | return null;
|
| 174 | }
|
| 175 | const value = parseInt(response.slice(3), 16);
|
| 176 | return Number.isNaN(value) ? null : value;
|
| 177 | }
|
| 178 |
|
| 179 | async queryNickname() {
|
| 180 | const raw = await this.query('QT');
|
| 181 | if (raw != null && raw.trim()) this.name = raw.trim();
|
| 182 | }
|
| 183 |
|
| 184 | async writeNickname(nickname) {
|
| 185 | if (!this.transport || this.err != null || nickname == null) return false;
|
| 186 | nickname = nickname.trim().slice(0, 16);
|
| 187 | const ok = await this.command(`ST,${nickname}`);
|
| 188 | if (ok) this.name = nickname;
|
| 189 | return ok;
|
| 190 | }
|
| 191 |
|
| 192 | async reboot() {
|
| 193 | if (!this.transport || this.err != null) return false;
|
| 194 | await this.transport.write('RB\r').catch(() => {});
|
| 195 | await this.disconnect();
|
| 196 | return true;
|
| 197 | }
|
| 198 |
|
| 199 | async bootload() {
|
| 200 | if (!this.transport || this.err != null) return false;
|
| 201 | await this.transport.write('BL\r').catch(() => {});
|
| 202 | await this.disconnect();
|
| 203 | return true;
|
| 204 | }
|
| 205 |
|
| 206 | // --- EBB RAM variables (volatile; cleared on reset) ---
|
| 207 |
|
| 208 | async varWrite(value, index) {
|
| 209 | if (!this.transport || this.err != null) return false;
|
| 210 | await this.command(`SL,${value},${index}`);
|
| 211 | return this.err == null;
|
| 212 | }
|
| 213 |
|
| 214 | async varRead(index) {
|
| 215 | if (!this.transport || this.err != null) return null;
|
| 216 | const value = await this.query(`QL,${index}`);
|
| 217 | if (this.err != null) return null;
|
| 218 | return parseInt(value, 10);
|
| 219 | }
|
| 220 |
|
| 221 | /** Store a signed 32-bit integer big-endian across four byte slots. */
|
| 222 | async varWriteInt32(value, startIndex) {
|
| 223 | if (!this.transport || this.err != null) return false;
|
| 224 | const u = (value | 0) >>> 0;
|
| 225 | for (const byte of [u >>> 24, (u >>> 16) & 255, (u >>> 8) & 255, u & 255]) {
|
| 226 | await this.varWrite(byte, startIndex++);
|
| 227 | }
|
| 228 | return this.err == null;
|
| 229 | }
|
| 230 |
|
| 231 | async varReadInt32(startIndex) {
|
| 232 | if (!this.transport || this.err != null) return null;
|
| 233 | const bytes = [];
|
| 234 | for (let i = 0; i < 4; i++) bytes.push(await this.varRead(startIndex + i));
|
| 235 | if (this.err != null) return null;
|
| 236 | return ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) | 0;
|
| 237 | }
|
| 238 |
|
| 239 | // --- Motion (ebb3_motion.EBBMotionWrap) ---
|
| 240 |
|
| 241 | /** Hardware-timed pause, in ms, split into <= 750 ms SM delays. */
|
| 242 | async timedPause(ms) {
|
| 243 | while (ms > 0 && this.transport && this.err == null) {
|
| 244 | const chunk = ms > 750 ? 750 : Math.max(ms, 1);
|
| 245 | await this.command(`SM,${chunk},0,0`);
|
| 246 | ms -= chunk;
|
| 247 | }
|
| 248 | }
|
| 249 |
|
| 250 | /** Constant-rate move. Note the argument order: axis 2 first, as in plotink. */
|
| 251 | async xyMove(deltaAxis2, deltaAxis1, durationMs) {
|
| 252 | if (!this.transport || this.err != null) return;
|
| 253 | await this.command(`SM,${durationMs},${deltaAxis1},${deltaAxis2}`);
|
| 254 | }
|
| 255 |
|
| 256 | /** Absolute move to a step position (or home, if positions are omitted). */
|
| 257 | async absMove(rate, position1 = null, position2 = null) {
|
| 258 | if (!this.transport || this.err != null) return;
|
| 259 | await this.command(position1 != null && position2 != null
|
| 260 | ? `HM,${rate},${position1},${position2}` : `HM,${rate}`);
|
| 261 | }
|
| 262 |
|
| 263 | async motorsDisable() {
|
| 264 | if (!this.transport || this.err != null) return;
|
| 265 | await this.command('EM,0,0');
|
| 266 | }
|
| 267 |
|
| 268 | /** Enable motors; 1 = 16x microstepping ... 5 = full steps, 0 = off. */
|
| 269 | async motorsEnable(res1, res2) {
|
| 270 | if (!this.transport || this.err != null) return;
|
| 271 | res1 = Math.min(Math.max(Math.trunc(res1), 0), 5);
|
| 272 | res2 = Math.min(Math.max(Math.trunc(res2), 0), 5);
|
| 273 | if (res1 !== res2 && res1 * res2 === 0) await this.command('CU,50,0');
|
| 274 | if (res1 === 0 && res2 !== 0) {
|
| 275 | // Only motor 1 sets the global step mode; set it first if needed.
|
| 276 | const current = await this.motorsQueryEnabled();
|
| 277 | if (current == null) return;
|
| 278 | let oldRes = 0;
|
| 279 | if (current[1] !== 0) oldRes = current[1];
|
| 280 | if (current[0] !== 0) oldRes = current[0];
|
| 281 | if (oldRes !== res2) await this.command(`EM,${res2},${res2}`);
|
| 282 | }
|
| 283 | await this.command(`EM,${res1},${res2}`);
|
| 284 | }
|
| 285 |
|
| 286 | /** Motor enable states as EM-style codes [res1, res2], or null. */
|
| 287 | async motorsQueryEnabled() {
|
| 288 | if (!this.transport || this.err != null) return null;
|
| 289 | const response = await this.query('QE');
|
| 290 | if (response == null) return null;
|
| 291 | const map = { 16: 1, 8: 2, 4: 3, 2: 4, 1: 5, 0: 0 };
|
| 292 | const [a, b] = response.split(',').map((s) => parseInt(s, 10));
|
| 293 | return [map[a], map[b]];
|
| 294 | }
|
| 295 |
|
| 296 | async querySteps() {
|
| 297 | if (!this.transport || this.err != null) return null;
|
| 298 | const result = await this.query('QS');
|
| 299 | if (this.err != null || result == null) return null;
|
| 300 | const [a, b] = result.trim().split(',').map((s) => parseInt(s, 10));
|
| 301 | return [a, b];
|
| 302 | }
|
| 303 |
|
| 304 | async clearSteps() {
|
| 305 | if (!this.transport || this.err != null) return;
|
| 306 | await this.command('CS');
|
| 307 | }
|
| 308 |
|
| 309 | async clearAccumulators() {
|
| 310 | if (!this.transport || this.err != null) return;
|
| 311 | await this.command('T3,1,0,0,0,0,0,0,3');
|
| 312 | }
|
| 313 |
|
| 314 | async penLower(delayMs, pin = null) {
|
| 315 | if (!this.transport || this.err != null) return;
|
| 316 | await this.command(pin ? `SP,0,${delayMs},${pin}` : `SP,0,${delayMs}`);
|
| 317 | }
|
| 318 |
|
| 319 | async penRaise(delayMs, pin = null) {
|
| 320 | if (!this.transport || this.err != null) return;
|
| 321 | await this.command(pin ? `SP,1,${delayMs},${pin}` : `SP,1,${delayMs}`);
|
| 322 | }
|
| 323 |
|
| 324 | async dioBConfig(pin, state, direction) {
|
| 325 | if (!this.transport || this.err != null) return;
|
| 326 | await this.command(`PO,B,${pin},${state}`);
|
| 327 | await this.command(`PD,B,${pin},${direction}`);
|
| 328 | }
|
| 329 |
|
| 330 | async dioBSet(pin, state) {
|
| 331 | if (!this.transport || this.err != null) return;
|
| 332 | await this.command(`PO,B,${pin},${state}`);
|
| 333 | }
|
| 334 |
|
| 335 | async dioBRead(pin) {
|
| 336 | if (!this.transport || this.err != null) return null;
|
| 337 | const response = await this.query(`PI,B,${pin}`);
|
| 338 | if (response == null) return null;
|
| 339 | return Boolean(parseInt(response, 10));
|
| 340 | }
|
| 341 |
|
| 342 | /** Pen-down servo position (SC,5). */
|
| 343 | async penPosDown(value) {
|
| 344 | if (!this.transport || this.err != null) return;
|
| 345 | await this.command(`SC,5,${value}`);
|
| 346 | }
|
| 347 |
|
| 348 | /** Pen-up servo position (SC,4). */
|
| 349 | async penPosUp(value) {
|
| 350 | if (!this.transport || this.err != null) return;
|
| 351 | await this.command(`SC,4,${value}`);
|
| 352 | }
|
| 353 |
|
| 354 | async penRateDown(rate) {
|
| 355 | if (!this.transport || this.err != null) return;
|
| 356 | await this.command(`SC,12,${rate}`);
|
| 357 | }
|
| 358 |
|
| 359 | async penRateUp(rate) {
|
| 360 | if (!this.transport || this.err != null) return;
|
| 361 | await this.command(`SC,11,${rate}`);
|
| 362 | }
|
| 363 |
|
| 364 | async servoTimeout(timeoutMs, state = null) {
|
| 365 | if (!this.transport || this.err != null) return;
|
| 366 | await this.command(state == null ? `SR,${timeoutMs}` : `SR,${timeoutMs},${state}`);
|
| 367 | }
|
| 368 |
|
| 369 | /** True if motor power seems present (QC V+ reading >= threshold). */
|
| 370 | async queryVoltage(threshold = 250) {
|
| 371 | if (!this.transport || this.err != null) return null;
|
| 372 | const response = await this.query('QC');
|
| 373 | if (response == null) return null;
|
| 374 | const parts = response.split(',');
|
| 375 | if (parts.length < 2) return null;
|
| 376 | return parseInt(parts[1], 10) >= threshold;
|
| 377 | }
|
| 378 | }
|