CI Log in

zach/dotter

src/sim/ebb-sim.js 581 lines · 18.4 KB · javascript
1
// A simulated NextDraw: an EBB v3 firmware emulator plus a physical model
2
// of the carriage. It implements the same transport interface as the
3
// WebSerial transport, so the real driver code runs against it unchanged.
4
//
5
// What it models:
6
// - The serial protocol: legacy syntax until CU,10,1, then future syntax.
7
// - Motion commands SM, HM, T3, TD executed step by step (T3/TD per 40 µs
8
//   tick, exactly as the firmware accumulators do), on mixed axes
9
//   (motor1 = x + y, motor2 = x - y), with step counters and accumulators.
10
// - A physical carriage in a box [0, travelX] x [0, travelY], with a limit
11
//   switch at X = 0 on pin B1, freewheeling of disabled motors (the homing
12
//   routine relies on this), and the firmware's limit-switch stop (CU,51/52).
13
// - Pen servo state, EBB RAM variables, nickname, status byte, motor power,
14
//   the PRG button, and a FIFO timeline driven by a virtual clock.
15
//
16
// What it records:
17
// - log: every command received, with its virtual timestamp.
18
// - violations: anything that would be unsafe or wrong on a real machine:
19
//   leaving the travel area, pushing into a wall with both motors driven,
20
//   step rates above the firmware's 25 kHz limit, unknown commands, motion
21
//   ignored after a limit stop.
22
// - ink: polylines traced while the pen was down (physical inches).
23
// - travel: polylines traced while the pen was up.
24
25
const ACC = 2147483648; // 2^31
26
const UNITS_PER_INCH = 4064; // Physical grid: one 16x microstep on one axis moves 1/4064 in in X and Y.
27
const MICROSTEPS = { 1: 16, 2: 8, 3: 4, 4: 2, 5: 1 };
28
29
export class VirtualClock {
30
  constructor() {
31
    this.t = 0;
32
  }
33
34
  now() {
35
    return this.t;
36
  }
37
38
  async sleep(ms) {
39
    this.t += Math.max(0, ms);
40
  }
41
}
42
43
export class EBBSimulator {
44
  /**
45
   * @param {object} o
46
   * @param {VirtualClock} o.clock shared with the NextDraw instance
47
   * @param {number} [o.travelX] inches (NextDraw 8511: 11.81)
48
   * @param {number} [o.travelY] inches (NextDraw 8511: 8.58)
49
   * @param {number} [o.startX] initial carriage X, inches
50
   * @param {number} [o.startY] initial carriage Y, inches
51
   * @param {string} [o.firmware] firmware version string
52
   * @param {boolean} [o.powered] motor power supply connected
53
   * @param {boolean} [o.limitSwitch] limit switch present
54
   * @param {string} [o.nickname]
55
   */
56
  constructor({ clock = new VirtualClock(), travelX = 11.81, travelY = 8.58, startX = 0, startY = 0,
57
    firmware = '3.0.3', powered = true, limitSwitch = true, nickname = '' } = {}) {
58
    this.clock = clock;
59
    this.firmware = firmware;
60
    this.xMaxU = Math.round(travelX * UNITS_PER_INCH);
61
    this.yMaxU = Math.round(travelY * UNITS_PER_INCH);
62
    this.X = Math.round(startX * UNITS_PER_INCH);
63
    this.Y = Math.round(startY * UNITS_PER_INCH);
64
    this.powered = powered;
65
    this.limitSwitchPresent = limitSwitch;
66
    this.nickname = nickname;
67
    this.isOpen = false;
68
    this.unplugged = false;
69
    this.inbuf = '';
70
    this.out = [];
71
    this.log = [];
72
    this.violations = [];
73
    this.ink = [];
74
    this.travel = [];
75
    this.stats = { steps: 0, maxRate: 0, motionMs: 0, peakSpeedUp: 0, peakSpeedDown: 0 };
76
    this.reset();
77
  }
78
79
  /** Power-on state of the EBB (does not move the carriage). */
80
  reset() {
81
    this.future = false;
82
    this.vars = new Uint8Array(32);
83
    this.steps = [0, 0];
84
    this.accum = [0, 0];
85
    this.enabled = [false, false];
86
    this.stepMode = 1; // EM code: 1 = 16x microstepping
87
    this.autoEnable = true;
88
    this.limitMask = 0;
89
    this.limitTarget = 0;
90
    this.limitTriggered = false;
91
    this.servoMin = 22565; // pen-up position at boot
92
    this.servoMax = 15302; // pen-down position at boot
93
    this.servoRateUp = 0;
94
    this.servoRateDown = 0;
95
    this.servoChannels = 8;
96
    this.penUp = true;
97
    this.button = false;
98
    this.powerLostFlag = false;
99
    this.powerThreshold = 0;
100
    this.busyUntil = 0;
101
    this.portB = { dir: new Array(8).fill(1), out: new Array(8).fill(0) };
102
    this.currentPath = null;
103
    this.pathStart = this.position;
104
  }
105
106
  // --- Test controls ---
107
108
  pressButton() {
109
    this.button = true;
110
  }
111
112
  setPower(on) {
113
    this.powered = on;
114
    if (!on && this.powerThreshold > 0) this.powerLostFlag = true;
115
  }
116
117
  unplug() {
118
    this.unplugged = true;
119
  }
120
121
  get position() {
122
    return [this.X / UNITS_PER_INCH, this.Y / UNITS_PER_INCH];
123
  }
124
125
  // --- Transport interface ---
126
127
  async open() {
128
    if (this.unplugged) throw new Error('Device not found');
129
    this.isOpen = true;
130
  }
131
132
  async close() {
133
    this.isOpen = false;
134
    this.endPath();
135
  }
136
137
  async clearInput() {
138
    this.out = [];
139
  }
140
141
  async write(text) {
142
    if (this.unplugged || !this.isOpen) throw new Error('Port is not open');
143
    this.inbuf += text;
144
    let idx;
145
    while ((idx = this.inbuf.indexOf('\r')) >= 0) {
146
      const cmd = this.inbuf.slice(0, idx).replace(/^\n+/, '');
147
      this.inbuf = this.inbuf.slice(idx + 1);
148
      if (cmd.length) this.handle(cmd);
149
    }
150
  }
151
152
  async readLine() {
153
    if (this.unplugged) throw new Error('Device disconnected');
154
    return this.out.length ? this.out.shift() : '';
155
  }
156
157
  // --- Command processing ---
158
159
  reply(name, data = null) {
160
    if (this.future) this.out.push(data == null ? `${name}\n` : `${name},${data}\n`);
161
    else this.out.push(data == null ? 'OK\r\n' : `${data}\r\nOK\r\n`);
162
  }
163
164
  error(text) {
165
    this.out.push(`!8 Err: ${text}${this.future ? '\n' : '\r\n'}`);
166
  }
167
168
  violation(kind, detail) {
169
    this.violations.push({ kind, detail, t: this.clock.now(), position: this.position });
170
  }
171
172
  handle(cmd) {
173
    this.log.push({ t: this.clock.now(), cmd });
174
    const parts = cmd.split(',');
175
    const name = parts[0].toUpperCase();
176
    const args = parts.slice(1);
177
    const num = (i, dflt = 0) => (args[i] === undefined || args[i] === '' ? dflt : Number(args[i]));
178
    switch (name) {
179
      case 'V':
180
        this.out.push(`EBBv13_and_above EB Firmware Version ${this.firmware}\r\n`);
181
        return;
182
      case 'CU':
183
        this.configure(num(0), num(1));
184
        this.reply('CU');
185
        return;
186
      case 'QT':
187
        this.reply('QT', this.nickname || null);
188
        return;
189
      case 'ST':
190
        this.nickname = args.join(',').slice(0, 16);
191
        this.reply('ST');
192
        return;
193
      case 'QG':
194
        this.reply('QG', this.statusByte().toString(16).toUpperCase().padStart(2, '0'));
195
        return;
196
      case 'QC':
197
        this.reply('QC', `${this.powered ? 400 : 6},${this.powered ? 305 : 4}`);
198
        return;
199
      case 'QE': {
200
        const code = (on) => (on ? MICROSTEPS[this.stepMode] : 0);
201
        this.reply('QE', `${code(this.enabled[0])},${code(this.enabled[1])}`);
202
        return;
203
      }
204
      case 'QS':
205
        this.reply('QS', `${this.steps[0]},${this.steps[1]}`);
206
        return;
207
      case 'CS':
208
        this.steps = [0, 0];
209
        this.accum = [0, 0];
210
        this.reply('CS');
211
        return;
212
      case 'SL':
213
        this.vars[num(1)] = num(0) & 255;
214
        this.reply('SL');
215
        return;
216
      case 'QL':
217
        this.reply('QL', String(this.vars[num(0)]));
218
        return;
219
      case 'PD':
220
        this.portB.dir[num(1)] = num(2);
221
        this.reply('PD');
222
        return;
223
      case 'PO':
224
        this.portB.out[num(1)] = num(2);
225
        this.reply('PO');
226
        return;
227
      case 'PI':
228
        this.reply('PI', String(this.readPin(num(1))));
229
        return;
230
      case 'SC':
231
        this.servoConfig(num(0), num(1));
232
        this.reply('SC');
233
        return;
234
      case 'SR':
235
        this.reply('SR');
236
        return;
237
      case 'SP':
238
        this.setPen(num(0), num(1), args[2]);
239
        this.reply('SP');
240
        return;
241
      case 'EM':
242
        this.enableMotors(num(0), args.length > 1 ? num(1) : null);
243
        this.reply('EM');
244
        return;
245
      case 'ES': {
246
        const interrupted = this.clock.now() < this.busyUntil ? 1 : 0;
247
        this.busyUntil = this.clock.now();
248
        if (num(0) === 1) this.enabled = [false, false];
249
        this.reply('ES', String(interrupted));
250
        return;
251
      }
252
      case 'SM':
253
        this.cmdSM(num(0), num(1), num(2), num(3));
254
        return;
255
      case 'HM':
256
        this.cmdHM(num(0), args.length >= 3 ? num(1) : 0, args.length >= 3 ? num(2) : 0);
257
        return;
258
      case 'T3':
259
        this.cmdT3(cmd, args.map(Number));
260
        return;
261
      case 'TD':
262
        this.cmdTD(cmd, args.map(Number));
263
        return;
264
      case 'R':
265
        this.reset();
266
        this.reply('R');
267
        return;
268
      case 'RB':
269
      case 'BL':
270
        this.reset();
271
        this.isOpen = false;
272
        return;
273
      default:
274
        this.violation('unknown-command', cmd);
275
        this.error(`Unknown command '${name}'`);
276
    }
277
  }
278
279
  configure(param, value) {
280
    if (param === 10) this.future = value === 1;
281
    else if (param === 50) this.autoEnable = value === 1;
282
    else if (param === 51) {
283
      this.limitMask = value & 255;
284
      if (this.limitMask === 0) this.limitTriggered = false;
285
    } else if (param === 52) this.limitTarget = value & 255;
286
    else if (param === 60) this.powerThreshold = value;
287
  }
288
289
  statusByte() {
290
    let s = 0;
291
    if (this.limitTriggered) s |= 128;
292
    if (this.powerLostFlag || (this.powerThreshold > 0 && !this.powered)) s |= 64;
293
    if (this.button) s |= 32;
294
    if (this.penUp) s |= 16;
295
    if (this.clock.now() < this.busyUntil) s |= 8 | 4 | 2;
296
    this.button = false;
297
    this.powerLostFlag = false;
298
    return s;
299
  }
300
301
  readPin(pin) {
302
    if (pin === 1) {
303
      // Normally-closed switch to ground: reads 1 when pressed or absent.
304
      if (!this.limitSwitchPresent) return 1;
305
      return this.X <= 0 ? 1 : 0;
306
    }
307
    return this.portB.out[pin] ?? 0;
308
  }
309
310
  servoConfig(param, value) {
311
    if (param === 4) this.servoMin = value;
312
    else if (param === 5) this.servoMax = value;
313
    else if (param === 8) this.servoChannels = value;
314
    else if (param === 11) this.servoRateUp = value;
315
    else if (param === 12) this.servoRateDown = value;
316
  }
317
318
  setPen(value, durationMs) {
319
    if (value === 0) {
320
      this.queue(durationMs);
321
      this.penUp = false;
322
    } else if (value === 1) {
323
      this.queue(durationMs);
324
      this.penUp = true;
325
    } else if (value === 2 || value === 3) {
326
      if (value === 3) this.servoMax = this.servoMin;
327
      this.penUp = true;
328
    }
329
    if (this.penUp) this.endPath();
330
  }
331
332
  enableMotors(e1, e2) {
333
    this.queue(0);
334
    if (e1 > 0) this.stepMode = e1;
335
    this.enabled[0] = e1 > 0;
336
    if (e2 != null) this.enabled[1] = e2 > 0;
337
    // EM resets the global step counters and clears a limit stop.
338
    this.steps = [0, 0];
339
    this.accum = [0, 0];
340
    this.limitTriggered = false;
341
  }
342
343
  /** Reserve FIFO time; returns the start time of this command. */
344
  queue(durationMs) {
345
    const start = Math.max(this.clock.now(), this.busyUntil);
346
    this.busyUntil = start + Math.max(0, durationMs);
347
    return start;
348
  }
349
350
  motionAllowed(name) {
351
    if (this.limitTriggered) {
352
      this.violation('motion-after-limit', `${name} ignored: limit switch stop is active`);
353
      this.reply(name);
354
      return false;
355
    }
356
    if (this.autoEnable) this.enabled = [true, true];
357
    return true;
358
  }
359
360
  cmdSM(durationMs, s1, s2, clear) {
361
    if (!(durationMs >= 1)) {
362
      this.error('SM duration must be >= 1');
363
      return;
364
    }
365
    if (Math.abs(s1) / durationMs > 25 || Math.abs(s2) / durationMs > 25) {
366
      this.violation('step-rate', `SM,${durationMs},${s1},${s2} exceeds 25000 steps/s`);
367
      this.error('Step rate too high');
368
      return;
369
    }
370
    if (s1 === 0 && s2 === 0) {
371
      this.queue(Math.min(durationMs, 100000));
372
      this.reply('SM');
373
      return;
374
    }
375
    if (!this.motionAllowed('SM')) return;
376
    if (clear & 1) this.accum[0] = 0;
377
    if (clear & 2) this.accum[1] = 0;
378
    const start = this.queue(durationMs);
379
    this.trackSpeed(s1 * 1000 / durationMs, s2 * 1000 / durationMs);
380
    const n = Math.max(Math.abs(s1), Math.abs(s2));
381
    let done1 = 0;
382
    let done2 = 0;
383
    for (let i = 1; i <= n; i++) {
384
      const t1 = Math.trunc(s1 * i / n);
385
      const t2 = Math.trunc(s2 * i / n);
386
      const stopped = this.stepBoth(t1 - done1, t2 - done2);
387
      done1 = t1;
388
      done2 = t2;
389
      if (stopped) {
390
        this.busyUntil = start + durationMs * i / n;
391
        break;
392
      }
393
    }
394
    this.markPath();
395
    this.stats.motionMs += durationMs;
396
    this.reply('SM');
397
  }
398
399
  cmdHM(rate, p1, p2) {
400
    if (!(rate >= 1 && rate <= 25000)) {
401
      this.error('HM rate out of range');
402
      return;
403
    }
404
    if (!this.motionAllowed('HM')) return;
405
    const d1 = p1 - this.steps[0];
406
    const d2 = p2 - this.steps[1];
407
    const n = Math.max(Math.abs(d1), Math.abs(d2));
408
    const durationMs = (n / rate) * 1000 + 5;
409
    this.queue(durationMs);
410
    let done1 = 0;
411
    let done2 = 0;
412
    for (let i = 1; i <= n; i++) {
413
      const t1 = Math.round(d1 * i / n);
414
      const t2 = Math.round(d2 * i / n);
415
      if (this.stepBoth(t1 - done1, t2 - done2)) break;
416
      done1 = t1;
417
      done2 = t2;
418
    }
419
    this.markPath();
420
    this.stats.motionMs += durationMs;
421
    this.reply('HM');
422
  }
423
424
  cmdT3(cmd, a) {
425
    const [intervals, r1, a1, j1, r2, a2, j2, clear = 0] = a;
426
    if (!(intervals >= 1) || a.slice(0, 7).some((v) => !Number.isInteger(v))) {
427
      this.violation('bad-command', cmd);
428
      this.error('Bad T3 parameters');
429
      return;
430
    }
431
    if (!this.motionAllowed('T3')) return;
432
    if (clear & 1) this.accum[0] = 0;
433
    if (clear & 2) this.accum[1] = 0;
434
    this.queue(intervals / 25);
435
    this.runT3(cmd, intervals, r1, a1, j1, r2, a2, j2);
436
    this.markPath();
437
    this.stats.motionMs += intervals / 25;
438
    this.reply('T3');
439
  }
440
441
  cmdTD(cmd, a) {
442
    const [intervals, r1a, r1b, a1, j1, r2a, r2b, a2, j2, clear = 0] = a;
443
    if (!(intervals >= 1) || a.slice(0, 9).some((v) => !Number.isInteger(v))) {
444
      this.violation('bad-command', cmd);
445
      this.error('Bad TD parameters');
446
      return;
447
    }
448
    if (!this.motionAllowed('TD')) return;
449
    if (clear & 1) this.accum[0] = 0;
450
    if (clear & 2) this.accum[1] = 0;
451
    this.queue((2 * intervals) / 25);
452
    // The firmware expands TD into two T3 moves.
453
    if (!this.runT3(cmd, intervals, r1a, 0, j1, r2a, 0, j2)) {
454
      this.runT3(cmd, intervals, r1b, a1, -j1, r2b, a2, -j2);
455
    }
456
    this.markPath();
457
    this.stats.motionMs += (2 * intervals) / 25;
458
    this.reply('TD');
459
  }
460
461
  /** Tick-accurate T3 execution. Returns true if stopped by the limit switch. */
462
  runT3(cmd, intervals, r1, a1, j1, r2, a2, j2) {
463
    // Firmware pre-adjusts each rate by -Accel/2 + Jerk/6 (integer division).
464
    let rate1 = r1 - Math.trunc(a1 / 2) + Math.trunc(j1 / 6);
465
    let rate2 = r2 - Math.trunc(a2 / 2) + Math.trunc(j2 / 6);
466
    let acc1 = a1;
467
    let acc2 = a2;
468
    let flagged = false;
469
    for (let t = 0; t < intervals; t++) {
470
      rate1 += acc1;
471
      acc1 += j1;
472
      rate2 += acc2;
473
      acc2 += j2;
474
      const peak = Math.max(Math.abs(rate1), Math.abs(rate2));
475
      if (peak > this.stats.maxRate) this.stats.maxRate = peak;
476
      this.trackSpeed(rate1 * 25000 / ACC, rate2 * 25000 / ACC);
477
      if (peak >= ACC && !flagged) {
478
        flagged = true;
479
        this.violation('step-rate', `${cmd}: rate ${peak} >= 2^31 (more than one step per 40 µs)`);
480
      }
481
      this.accum[0] += rate1;
482
      this.accum[1] += rate2;
483
      let d1 = 0;
484
      let d2 = 0;
485
      while (this.accum[0] >= ACC) { this.accum[0] -= ACC; d1++; }
486
      while (this.accum[0] < 0) { this.accum[0] += ACC; d1--; }
487
      while (this.accum[1] >= ACC) { this.accum[1] -= ACC; d2++; }
488
      while (this.accum[1] < 0) { this.accum[1] += ACC; d2--; }
489
      if ((d1 || d2) && this.stepBoth(d1, d2)) {
490
        this.busyUntil = this.clock.now() + (t + 1) / 25;
491
        return true;
492
      }
493
    }
494
    return false;
495
  }
496
497
  /** Record peak carriage speed (in/s) from motor step rates (steps/s). */
498
  trackSpeed(v1, v2) {
499
    const inchPerStep = 2 * (16 / MICROSTEPS[this.stepMode]) / UNITS_PER_INCH;
500
    const vx = ((v1 + v2) / 2) * inchPerStep;
501
    const vy = ((v1 - v2) / 2) * inchPerStep;
502
    const speed = Math.hypot(vx, vy);
503
    const key = this.penUp ? 'peakSpeedUp' : 'peakSpeedDown';
504
    if (!(speed <= this.stats[key])) this.stats[key] = speed;
505
  }
506
507
  /**
508
   * Take d1 steps on motor 1 and d2 on motor 2 (normally -1..1 each) and move
509
   * the carriage. Returns true if a limit-switch stop fired.
510
   */
511
  stepBoth(d1, d2) {
512
    this.steps[0] += d1;
513
    this.steps[1] += d2;
514
    this.stats.steps += Math.abs(d1) + Math.abs(d2);
515
    // Physical motor travel per step, in grid units of (x+y) or (x-y).
516
    const k = 2 * (16 / MICROSTEPS[this.stepMode]);
517
    const m1 = this.enabled[0] ? d1 * k : 0;
518
    const m2 = this.enabled[1] ? d2 * k : 0;
519
    let dx;
520
    let dy;
521
    if (this.enabled[0] && this.enabled[1]) {
522
      dx = (m1 + m2) / 2;
523
      dy = (m1 - m2) / 2;
524
    } else if (this.enabled[0]) {
525
      // Motor 2 freewheels: the carriage moves diagonally until it meets a
526
      // Y wall, then slides along it.
527
      dx = m1 / 2;
528
      dy = m1 / 2;
529
      if (this.Y + dy < 0) { dy = -this.Y; dx = m1 - dy; }
530
      if (this.Y + dy > this.yMaxU) { dy = this.yMaxU - this.Y; dx = m1 - dy; }
531
    } else if (this.enabled[1]) {
532
      dx = m2 / 2;
533
      dy = -m2 / 2;
534
      if (this.Y + dy < 0) { dy = -this.Y; dx = m2 + dy; }
535
      if (this.Y + dy > this.yMaxU) { dy = this.yMaxU - this.Y; dx = m2 + dy; }
536
    } else {
537
      return false; // Both motors off: steps are counted but nothing moves.
538
    }
539
    let nx = this.X + dx;
540
    let ny = this.Y + dy;
541
    // Sub-step rounding and staircase stepping can touch just past the
542
    // nominal bounds; allow that much (it is within belt/stop compliance).
543
    // Anything further is driving the carriage into the frame.
544
    const tol = 2; // grid units (~0.0005 in)
545
    if (nx < -tol || nx > this.xMaxU + tol || ny < -tol || ny > this.yMaxU + tol) {
546
      this.violation('out-of-travel',
547
        `Carriage driven to (${(nx / UNITS_PER_INCH).toFixed(4)}, ${(ny / UNITS_PER_INCH).toFixed(4)}) in`);
548
    }
549
    nx = Math.min(Math.max(nx, -tol), this.xMaxU + tol);
550
    ny = Math.min(Math.max(ny, -tol), this.yMaxU + tol);
551
    this.X = nx;
552
    this.Y = ny;
553
    if (this.limitMask) {
554
      const pins = this.readPin(1) << 1;
555
      if ((pins & this.limitMask) === (this.limitTarget & this.limitMask)) {
556
        this.limitTriggered = true;
557
        return true;
558
      }
559
    }
560
    return false;
561
  }
562
563
  /** Append the carriage position to the current ink or travel polyline. */
564
  markPath() {
565
    const p = this.position;
566
    const list = this.penUp ? this.travel : this.ink;
567
    if (!this.currentPath || this.currentPath.penUp !== this.penUp) {
568
      const start = this.pathStart;
569
      this.endPath();
570
      this.currentPath = { penUp: this.penUp, points: [start] };
571
      list.push(this.currentPath.points);
572
    }
573
    this.currentPath.points.push(p);
574
    this.pathStart = p;
575
  }
576
577
  endPath() {
578
    this.currentPath = null;
579
    this.pathStart = this.position;
580
  }
581
}