Log in
src/ebb.js 378 lines · 12.7 KB · javascript
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
}