frugal-iot-client 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -678,6 +678,27 @@ polling is involved.
678
678
  `Monitor` also works standalone, requesting a port if none is held, so a running board can be watched
679
679
  without flashing it. It is stopped before `Connect` since esptool cannot open a port the monitor holds.
680
680
 
681
+ It also survives the port going away, and follows the device to the port it comes back as. Chrome
682
+ does not revive a `SerialPort` whose device went away: a board that resets or is replugged on native
683
+ USB re-enumerates and appears as a *new* object, so reopening the one being held fails for ever.
684
+ `monitorCandidates()` therefore asks `navigator.serial.getPorts()` on every attempt and tries each
685
+ granted port whose `getInfo()` matches the device's vendor and product ids, keeping whichever opens -
686
+ and putting it in `state.port`, so Connect and Flash use the live one too. A port that never went
687
+ away is returned by `getPorts()` as the same object, so the ordinary case is unchanged.
688
+
689
+ A matching USB device is required, never merely a port that happens to be there. A Mac always offers
690
+ Bluetooth-Incoming-Port and debug-console, and quietly monitoring one of those is worse than not
691
+ reconnecting at all; neither has a `usbVendorId`. When the port being held has no vendor id either -
692
+ the user picked something that is not a USB device - there is nothing to match on, so only that port
693
+ is retried rather than guessing at a replacement. A frugal-iot node deep-sleeps between readings, and on native
694
+ USB that takes the serial port with it - the stream ends and the port cannot be opened again until
695
+ the board wakes, which is normal operation rather than a fault. `monitorRun()` therefore loops:
696
+ anything but **Stop** leads back to waiting for the port, retrying every `MonitorRetryMs` and saying
697
+ so once per outage rather than once per attempt. Two things are deliberately first-pass only - the
698
+ RTS pulse that restarts the board (a reconnect must not reset a board that has just woken) and the
699
+ 25s silence deadline that catches a wrong monitor speed. After that a `read()` with no deadline is
700
+ exactly right: it settles when the board wakes, or when Stop cancels it.
701
+
681
702
  ### Serial monitor speed
682
703
 
683
704
  frugal-iot's `startSerial()` picks the rate by toolchain, so the boot log needs a selector rather
@@ -796,6 +817,15 @@ rejects it independently, so we know whether our check is the only guard or a se
796
817
  Two related refusals in the same code path are likewise untested: an app larger than the app
797
818
  partition, and a `<chip>-<flashsize>` combination absent from `boards.json`.
798
819
 
820
+ ### The ⚡ across two cards
821
+
822
+ Since the project's back became cards (CARDS_UX.md 11) the OTA list and the flasher are separate
823
+ `mqtt-admin` elements, and the Flash card is not built until it is opened - so neither can hold a
824
+ reference to the other. The bolt fires `frugaliot:flashfile` on `document`; `mqtt-projectback` opens
825
+ the Flash card, scrolls to it, and calls `flashRemote()` on its `mqtt-admin`, which is the only
826
+ thing that can see the `mqtt-flash` in its own shadow root. The bolt is left out entirely without
827
+ OTAFLASH, since there is then no Flash card for it to open.
828
+
799
829
  ### 8. UI polish
800
830
 
801
831
  Nothing structural — the flow works but is a little unintuitive. Known rough edges:
package/flash.js CHANGED
@@ -23,9 +23,15 @@ const FlashBaudRates = [921600, 460800, 230400, 115200];
23
23
  // 74880 is the ESP8266 ROM's own boot-message rate
24
24
  const MonitorBaudRates = [460800, 115200, 921600, 74880];
25
25
  const BootLogMs = 25000; // startSerial() waits 5s before printing, then WiFi/captive portal follows
26
+ // How often the monitor tries the port again while it is gone - a deep sleep is measured in
27
+ // seconds to minutes, so there is nothing to gain from trying harder than this
28
+ const MonitorRetryMs = 1000;
26
29
  // esptool-js's readFlash waits FLASH_READ_TIMEOUT (100s) for its first packet, which stalls Connect
27
30
  // on any board that does not answer the stub's read command
28
31
  const FlashReadMs = 8000;
32
+ // How long to wait for the port to close before saying it did not - esptool-js's disconnect() waits
33
+ // on the streams unlocking with no deadline of its own
34
+ const TransportReleaseMs = 5000;
29
35
  // JEDEC capacity byte -> size, mirroring esptool-js's own table so we can tell a real detection from
30
36
  // its silent 4MB fallback, which on an S3 is also the signature of unsupported octal flash
31
37
  const DetectedFlashSizes = {
@@ -36,6 +42,14 @@ const DetectedFlashSizes = {
36
42
  0x39: "32MB", 0x3a: "64MB",
37
43
  };
38
44
 
45
+ // Promise.race leaves the loser's timer running - on a 25s deadline that holds the event loop long
46
+ // after the answer arrived, which is also what kept the test run alive for half a minute
47
+ function withTimeout(promise, ms, value) {
48
+ let timer = null;
49
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(value), ms); });
50
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
51
+ }
52
+
39
53
  // 32-byte records: magic u16, type, subtype, offset u32, size u32, label[16], flags u32
40
54
  function partitionsParse(bytes) {
41
55
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
@@ -97,19 +111,29 @@ class MqttFlash extends HTMLElementExtended {
97
111
  constructor(props) {
98
112
  super(props);
99
113
  this.state = {elements: {}, log: [], app: null, device: null, busy: false,
100
- baudrate: FlashBaudRates[0], monitorBaudrate: MonitorBaudRates[0]};
114
+ baudrate: FlashBaudRates[0], monitorBaudrate: MonitorBaudRates[0], monitorRetryMs: MonitorRetryMs};
101
115
  }
102
116
  get supported() { return 'serial' in navigator; }
103
117
  // esploader goes null after flashing, so a second flash needs a reconnect rather than a stale loader
104
118
  get ready() {
105
119
  return !!(this.state.app && this.state.device && this.state.esploader && !this.state.device.refusal && !this.state.busy);
106
120
  }
121
+ /*
122
+ * The port stays open until this runs, and WebSerial then refuses the next open with "The port is
123
+ * already open" - so every path out of a connect or a flash, failed ones included, must reach here.
124
+ *
125
+ * disconnect() waits for the streams to unlock with no deadline of its own, so it is bounded:
126
+ * a reader that never releases would otherwise leave the button disabled with nothing said.
127
+ */
107
128
  async transportRelease() {
108
- if (this.state.transport) {
109
- try { await this.state.transport.disconnect(); } catch (e) { XXX(`transport disconnect: ${e.message}`); }
110
- }
129
+ const transport = this.state.transport;
111
130
  this.state.transport = null;
112
131
  this.state.esploader = null;
132
+ if (!transport) return;
133
+ const released = transport.disconnect().catch((e) => { XXX(`transport disconnect: ${e.message}`); });
134
+ if (await withTimeout(released, TransportReleaseMs, 'timeout') === 'timeout') {
135
+ this.logLine("The serial port did not close - unplug the board or reload the page before connecting again.");
136
+ }
113
137
  }
114
138
 
115
139
  // ---- logging ----
@@ -151,7 +175,7 @@ class MqttFlash extends HTMLElementExtended {
151
175
  this.statusRender();
152
176
  });
153
177
  }
154
- // Called by MqttAdmin when the ⚡ beside a server-hosted OTA file is clicked
178
+ // Reached from the ⚡ in the OTA card, by way of mqtt-projectback and MqttAdmin.flashRemote
155
179
  setRemoteSource(org, path) {
156
180
  const url = `/ota_get/${org}/${path}`;
157
181
  this.logLine(`Fetching ${url}`);
@@ -172,7 +196,7 @@ class MqttFlash extends HTMLElementExtended {
172
196
  this.state.busy = true;
173
197
  this.state.error = null;
174
198
  this.statusRender();
175
- portPromise
199
+ this.state.connectDone = portPromise // kept so a test can await the click
176
200
  .then((port) => this.connectAndInspect(port))
177
201
  .catch((e) => {
178
202
  this.logLine(`Error: ${e.message}`);
@@ -181,20 +205,24 @@ class MqttFlash extends HTMLElementExtended {
181
205
  })
182
206
  .then(() => { this.state.busy = false; this.statusRender(); });
183
207
  }
208
+ // A seam: the real thing is 214KB of esptool-js and wants a board on the other end of a port
209
+ esptoolImport() { return import('esptool-js'); }
184
210
  async connectAndInspect(port) {
185
211
  await this.monitorStop(); // the monitor holds the port open, so esptool cannot have it
186
212
  await this.transportRelease(); // clicking Connect twice must not stack transports on one port
187
- const {ESPLoader, Transport} = await import('esptool-js'); // 214KB, so not loaded until first use
213
+ const {ESPLoader, Transport} = await this.esptoolImport(); // not loaded until first use
188
214
  const transport = new Transport(port, true);
189
215
  const esploader = new ESPLoader({transport, baudrate: this.state.baudrate, terminal: this.terminal});
216
+ // Recorded before main(), which opens the port: a failure after that has to be releasable, or
217
+ // the retry meets a port this element no longer knows it holds
218
+ this.state.port = port;
219
+ this.state.transport = transport;
220
+ this.state.esploader = esploader;
190
221
  const description = await esploader.main(); // returns the description, not the chip name
191
222
  const chipName = esploader.chip.CHIP_NAME;
192
223
  const flashIdCapacity = (await esploader.readFlashId()) >> 16 & 0xff;
193
224
  const flashSizeGuessed = !DetectedFlashSizes[flashIdCapacity];
194
225
  const flashSize = DetectedFlashSizes[flashIdCapacity] || await esploader.detectFlashSize();
195
- this.state.port = port;
196
- this.state.transport = transport;
197
- this.state.esploader = esploader;
198
226
  this.state.device = {chipName, description, flashSize, flashSizeGuessed};
199
227
  if (flashSizeGuessed) {
200
228
  this.logLine(`WARNING: flash size not recognised (capacity byte 0x${flashIdCapacity.toString(16)}), assuming ${flashSize}`);
@@ -239,8 +267,7 @@ class MqttFlash extends HTMLElementExtended {
239
267
  async readFlashBounded(offset, length) {
240
268
  const read = this.state.esploader.readFlash(offset, length)
241
269
  .catch((e) => { this.logLine(`(read of 0x${offset.toString(16)} failed: ${e.message})`); return null; });
242
- const timeout = new Promise((resolve) => setTimeout(() => resolve(null), FlashReadMs));
243
- const bytes = await Promise.race([read, timeout]);
270
+ const bytes = await withTimeout(read, FlashReadMs, null);
244
271
  if (!bytes) this.logLine(`(could not read 0x${offset.toString(16)} - skipping inspection)`);
245
272
  return bytes;
246
273
  }
@@ -419,27 +446,100 @@ class MqttFlash extends HTMLElementExtended {
419
446
  XXX(`boot log reset signals: ${e.message}`);
420
447
  }
421
448
  }
422
- // Reopen the port with plain WebSerial and stream it until stopped. Runs indefinitely once the
423
- // board is talking - the timeout only covers the case where nothing arrives at all.
449
+ // Reopen the port with plain WebSerial and stream it until stopped.
424
450
  monitorStart() {
425
451
  if (this.state.monitoring || !this.state.port) return this.state.monitorDone || Promise.resolve();
426
452
  this.state.monitoring = true;
427
- this.state.monitorDone = this.monitorStream(this.state.port)
428
- .catch((e) => XXX(`monitor stream: ${e.message}`)) // cleanup below must run either way
453
+ this.state.monitorDone = this.monitorRun(this.state.port)
454
+ .catch((e) => XXX(`monitor run: ${e.message}`)) // cleanup below must run either way
429
455
  .then(() => {
430
456
  this.state.monitoring = false;
431
457
  this.state.monitorReader = null;
458
+ this.logLine("---- monitor stopped ----");
432
459
  this.statusRender();
433
460
  });
434
461
  this.statusRender();
435
462
  return this.state.monitorDone;
436
463
  }
464
+ /*
465
+ * A board that deep-sleeps takes its USB serial with it - on native USB the port disappears
466
+ * mid-log and comes back only when the board wakes - so the monitor has to outlive the port, the
467
+ * way `pio device monitor` does. Stop is the only thing that ends this loop; every other way a
468
+ * stream can end leads back to waiting for the port.
469
+ */
470
+ async monitorRun(port) {
471
+ let streamed = false; // a stream has run, so a failure to open is the board going away
472
+ let waiting = false; // the outage has been reported: not once a second
473
+ while (this.state.monitoring) {
474
+ let err = null;
475
+ let opened = null;
476
+ for (const candidate of await this.monitorCandidates(port)) {
477
+ err = await this.monitorOpen(candidate);
478
+ if (!err) { opened = candidate; break; }
479
+ }
480
+ if (opened) {
481
+ if (waiting) this.logLine("---- port back ----");
482
+ waiting = false;
483
+ port = this.state.port = opened; // Connect and Flash must use the live one too
484
+ await this.monitorStream(port, !streamed); // only the first pass resets the board
485
+ streamed = true;
486
+ } else if (!waiting) {
487
+ waiting = true;
488
+ this.logLine(streamed ? `(port gone - waiting for the board: ${err})` : `(could not open port: ${err})`);
489
+ }
490
+ if (this.state.monitoring) await this.monitorWait(this.state.monitorRetryMs);
491
+ }
492
+ }
493
+ // Settled early by monitorStop, so Stop is not left waiting out a retry delay
494
+ monitorWait(ms) {
495
+ return new Promise((resolve) => {
496
+ this.state.monitorWake = resolve;
497
+ setTimeout(resolve, ms);
498
+ });
499
+ }
500
+ /*
501
+ * A device that re-enumerates comes back as a NEW SerialPort: the object being held stays lost
502
+ * for good, so reopening it can never succeed - which is why unplugging and plugging back in did
503
+ * not bring the monitor back, while Connect, which asks for a port afresh, did.
504
+ *
505
+ * So each attempt tries every port this page already has permission for that looks like the same
506
+ * device. The one being held comes first while it is still valid, since getPorts() hands back the
507
+ * very same object for a device that never went away.
508
+ */
509
+ async monitorCandidates(port) {
510
+ const info = (port && port.getInfo) ? port.getInfo() : {};
511
+ const granted = (navigator.serial && navigator.serial.getPorts)
512
+ ? await navigator.serial.getPorts().catch((e) => { XXX(`getPorts: ${e.message}`); return []; })
513
+ : [];
514
+ /*
515
+ * The same USB device and nothing else. A Mac always offers Bluetooth-Incoming-Port and
516
+ * debug-console, and quietly monitoring one of those is worse than not reconnecting at all -
517
+ * neither has a usbVendorId, so requiring a matching one rules them out. If the port being held
518
+ * has no vendor id either, there is nothing to match on and only it is retried: the user picked
519
+ * it themselves, and a port like that does not re-enumerate anyway.
520
+ */
521
+ const sameDevice = info.usbVendorId ? granted.filter((p) => {
522
+ const i = p.getInfo ? p.getInfo() : {};
523
+ return i.usbVendorId === info.usbVendorId && i.usbProductId === info.usbProductId
524
+ && !i.bluetoothServiceClassId;
525
+ }) : [];
526
+ return [...sameDevice, port].filter((p, i, all) => p && all.indexOf(p) === i);
527
+ }
528
+ async monitorOpen(port) {
529
+ try {
530
+ await port.open({baudRate: this.state.monitorBaudrate});
531
+ return null;
532
+ } catch (e) {
533
+ return e.message;
534
+ }
535
+ }
437
536
  // cancel() settles the pending read(), so no polling is needed to notice the stop
438
537
  monitorStop() {
439
538
  if (!this.state.monitoring) return Promise.resolve();
440
539
  this.state.monitoring = false;
441
540
  const reader = this.state.monitorReader;
442
541
  if (reader) reader.cancel().catch((e) => XXX(`monitor cancel: ${e.message}`));
542
+ if (this.state.monitorWake) this.state.monitorWake(); // it may be between attempts rather than reading
443
543
  return this.state.monitorDone || Promise.resolve();
444
544
  }
445
545
  onMonitor() {
@@ -454,32 +554,24 @@ class MqttFlash extends HTMLElementExtended {
454
554
  .then((port) => { this.state.port = port; return this.monitorStart(); })
455
555
  .catch((e) => { this.state.error = e.message; this.logLine(`Error: ${e.message}`); this.statusRender(); });
456
556
  }
457
- async monitorStream(port) {
458
- const baud = this.state.monitorBaudrate;
459
- this.logLine(`---- monitor (${baud} baud) ----`);
460
- try {
461
- await port.open({baudRate: baud});
462
- } catch (e) {
463
- this.logLine(`(could not open port: ${e.message})`);
464
- return;
465
- }
557
+ // Takes an open port and always closes it, so monitorRun can open it again
558
+ async monitorStream(port, firstPass) {
559
+ this.logLine(`---- monitor (${this.state.monitorBaudrate} baud) ----`);
466
560
  let reader = null;
467
561
  let quiet = true;
468
562
  try {
469
- await this.bootReset(port);
563
+ if (firstPass) await this.bootReset(port); // a reconnect must not reset a board that just woke
470
564
  if (!port.readable) throw new Error("port has no readable stream");
471
565
  const decoder = new TextDecoder();
472
566
  reader = port.readable.getReader();
473
567
  this.state.monitorReader = reader;
474
568
  const quietUntil = Date.now() + BootLogMs;
475
569
  while (this.state.monitoring) {
476
- // Until the first byte arrives, read() may never resolve, so it needs a deadline. Once the
477
- // board is talking, plain read() is right - monitorStop() settles it with cancel().
478
- const chunk = quiet
479
- ? await Promise.race([
480
- reader.read(),
481
- new Promise((resolve) => setTimeout(() => resolve('timeout'), Math.max(250, quietUntil - Date.now()))),
482
- ])
570
+ // On the first pass silence is worth reporting - it usually means the wrong speed - and
571
+ // read() on a quiet port may never resolve, so that needs a deadline. Afterwards the wait
572
+ // is the point: a sleeping board settles the read when it wakes, and Stop cancels it.
573
+ const chunk = (quiet && firstPass)
574
+ ? await withTimeout(reader.read(), Math.max(250, quietUntil - Date.now()), 'timeout')
483
575
  : await reader.read();
484
576
  if (chunk === 'timeout' || chunk.done) break;
485
577
  if (chunk.value && chunk.value.length) {
@@ -488,17 +580,16 @@ class MqttFlash extends HTMLElementExtended {
488
580
  }
489
581
  }
490
582
  } catch (e) {
491
- this.logLine(`(monitor ended: ${e.message})`);
583
+ this.logLine(`(stream ended: ${e.message})`);
492
584
  } finally {
493
585
  if (reader) {
494
586
  try { await reader.cancel(); } catch (e) { XXX(`monitor reader cancel: ${e.message}`); }
495
587
  try { reader.releaseLock(); } catch (e) { XXX(`monitor reader release: ${e.message}`); }
496
588
  }
497
589
  try { await port.close(); } catch (e) { XXX(`monitor port close: ${e.message}`); }
498
- if (quiet) {
499
- this.logLine("(no output - check the monitor speed, that the build defines ANY_DEBUG, or reconnect: a board on native USB re-enumerates after reset and needs picking again)");
590
+ if (quiet && firstPass) {
591
+ this.logLine("(no output yet - check the monitor speed and that the build defines ANY_DEBUG. Still listening: a board on native USB drops the port when it sleeps and is picked up again when it wakes.)");
500
592
  }
501
- this.logLine("---- monitor stopped ----");
502
593
  }
503
594
  }
504
595
 
package/frugaliot.css CHANGED
@@ -202,6 +202,8 @@ div.mqtt-text {
202
202
  }
203
203
  /* The \27f3 beside a heading: still a link, but underlining a glyph reads as a typo. */
204
204
  .refresh { text-decoration: none; }
205
+ /* An OTA row's glyphs: the padding is the whole hit area, so a mis-tap cannot reach delete. */
206
+ .otaicon { text-decoration: none; padding: 0 0.3em; }
205
207
  .message {margin: 0.5em; font-weight: bold; font-size: x-large; }
206
208
  /* An enrolment secret is a long unbroken string; without this it runs off a phone. */
207
209
  .enrolment__line { word-break: break-all; }
package/package.json CHANGED
@@ -41,5 +41,5 @@
41
41
  "unlink:hee": "rm -f node_modules/html-element-extended && npm install html-element-extended"
42
42
  },
43
43
  "type": "module",
44
- "version": "2.0.2"
44
+ "version": "2.0.4"
45
45
  }
@@ -0,0 +1,220 @@
1
+ // The flasher's hold on the serial port. WebSerial refuses a second open of the same port, so the
2
+ // element is the only thing that can close it - and a connect that fails is exactly when it must.
3
+ import './setup.js';
4
+ import { test, describe, before, beforeEach, afterEach } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+
7
+ let ports; // what a fake Transport did, in order
8
+
9
+ // A Transport that records rather than talking to a board. `fail` is thrown by main() after the
10
+ // port is open, which is what a board that will not sync looks like.
11
+ function fakeEsptool(fail) {
12
+ return async () => ({
13
+ Transport: class {
14
+ constructor(port) { this.port = port; ports.push('opened'); }
15
+ async disconnect() { ports.push('closed'); }
16
+ },
17
+ ESPLoader: class {
18
+ constructor(o) { this.transport = o.transport; this.chip = {CHIP_NAME: 'ESP8266', BOOTLOADER_FLASH_OFFSET: 0}; }
19
+ async main() { if (fail) throw new Error(fail); return 'ESP8266EX'; }
20
+ async readFlashId() { return 0x16 << 16; } // 4MB, so detectFlashSize is not called
21
+ },
22
+ });
23
+ }
24
+ function flashElement(fail) {
25
+ const flash = document.createElement('mqtt-flash');
26
+ document.body.append(flash);
27
+ flash.esptoolImport = fakeEsptool(fail);
28
+ flash.state.boards = {wholeImageChips: ['ESP8266']}; // stands in for the fetch of boards.json
29
+ return flash;
30
+ }
31
+ const connectButton = (flash) => [...flash.shadowRoot.querySelectorAll('button')]
32
+ .find((b) => b.textContent === 'Connect board');
33
+
34
+ before(async () => { await import('../flash.js'); });
35
+ beforeEach(() => {
36
+ ports = [];
37
+ // One SerialPort object for the run, as WebSerial hands back for the same device
38
+ const port = {};
39
+ Object.defineProperty(navigator, 'serial',
40
+ {value: {requestPort: async () => port}, configurable: true});
41
+ });
42
+ afterEach(() => { delete navigator.serial; });
43
+
44
+ describe('connecting to a board', () => {
45
+ test('a failure still closes the port, so Connect can be clicked again', async () => {
46
+ // It could not before: the transport was recorded only after main() succeeded, so the release
47
+ // on the way out had nothing to release and the retry met "The port is already open"
48
+ const flash = flashElement('Failed to connect to ESP32: Wrong boot mode detected');
49
+ connectButton(flash).click();
50
+ await flash.state.connectDone;
51
+ assert.deepEqual(ports, ['opened', 'closed']);
52
+ assert.match(flash.state.log.join('\n'), /Wrong boot mode/, 'the reason should be logged');
53
+ assert.equal(flash.state.transport, null, 'it should not still hold a transport');
54
+ assert.ok(!flash.state.busy, 'the button should be live again');
55
+ flash.remove();
56
+ });
57
+
58
+ test('a second try closes the first attempt before opening again', async () => {
59
+ const flash = flashElement('Failed to connect to ESP32');
60
+ connectButton(flash).click();
61
+ await flash.state.connectDone;
62
+ connectButton(flash).click();
63
+ await flash.state.connectDone;
64
+ assert.deepEqual(ports, ['opened', 'closed', 'opened', 'closed']);
65
+ flash.remove();
66
+ });
67
+
68
+ test('a board that answers leaves the port held, since flashing needs it', async () => {
69
+ const flash = flashElement(null);
70
+ connectButton(flash).click();
71
+ await flash.state.connectDone;
72
+ assert.deepEqual(ports, ['opened'], 'it should not have let go');
73
+ assert.equal(flash.state.device.chipName, 'ESP8266');
74
+ flash.remove();
75
+ });
76
+ });
77
+
78
+ /*
79
+ * The monitor across a deep sleep. A board on native USB takes the port with it when it sleeps, so
80
+ * the stream ends and the port cannot be opened again until it wakes - which is the normal case for
81
+ * a frugal-iot node, not a fault.
82
+ */
83
+ describe('the serial monitor', () => {
84
+ // Each entry is one open(): {fail} refuses it, otherwise a stream of `chunks` that then ends the
85
+ // way `end` says. No end at all leaves the read pending, as a live board does between lines.
86
+ function fakePort(attempts, info) {
87
+ const port = {opened: 0, closed: 0, signals: 0, readable: null};
88
+ if (info) port.getInfo = () => info;
89
+ port.open = async () => {
90
+ const a = attempts[port.opened++] || {fail: 'The device has been lost.'};
91
+ if (a.fail) throw new Error(a.fail);
92
+ port.readable = new ReadableStream({
93
+ start(c) {
94
+ (a.chunks || []).forEach((t) => c.enqueue(new TextEncoder().encode(t)));
95
+ if (a.end === 'error') c.error(new Error('The device has been lost.'));
96
+ if (a.end === 'done') c.close();
97
+ },
98
+ });
99
+ };
100
+ port.close = async () => { port.closed++; port.readable = null; };
101
+ port.setSignals = async () => { port.signals++; };
102
+ return port;
103
+ }
104
+ function monitoring(attempts, granted, info) {
105
+ const flash = document.createElement('mqtt-flash');
106
+ document.body.append(flash);
107
+ flash.state.monitorRetryMs = 5; // a second per retry is right for a board, not for a test
108
+ flash.state.port = fakePort(attempts, info);
109
+ if (granted) Object.defineProperty(navigator, 'serial',
110
+ {value: {getPorts: async () => granted()}, configurable: true});
111
+ flash.monitorStart();
112
+ return flash;
113
+ }
114
+ const ESP32 = {usbVendorId: 0x303a, usbProductId: 0x1001};
115
+ const log = (flash) => flash.state.log.join('\n');
116
+ // Stops the monitor before failing: a retry loop left running holds the test runner open
117
+ async function until(flash, test, what) {
118
+ for (let i = 0; i < 400 && !test(); i++) await new Promise((r) => setTimeout(r, 5));
119
+ if (!test()) { await flash.monitorStop(); assert.fail(what); }
120
+ }
121
+
122
+ test('picks the port up again when the board wakes, with no click', async () => {
123
+ const flash = monitoring([
124
+ {chunks: ['before sleep\n'], end: 'error'}, // the port goes as the board sleeps
125
+ {fail: 'The device has been lost.'}, // still asleep
126
+ {chunks: ['awake again\n']}, // back, and talking
127
+ ]);
128
+ await until(flash, () => log(flash).includes('awake again'), 'the monitor did not come back');
129
+ assert.match(log(flash), /port gone/, 'it should say why it went quiet');
130
+ assert.match(log(flash), /---- port back ----/);
131
+ await flash.monitorStop();
132
+ assert.equal(flash.state.port.opened, 3);
133
+ flash.remove();
134
+ });
135
+
136
+ test('follows the device to the new port it comes back as', async () => {
137
+ // Chrome does not revive a SerialPort whose device went away: a replugged board is a new object,
138
+ // and reopening the old one fails for ever. This is what a reset or a replug actually looks like.
139
+ const replugged = fakePort([{chunks: ['back from the dead\n']}], ESP32);
140
+ const flash = monitoring([{chunks: ['before\n'], end: 'error'}], () => [replugged], ESP32);
141
+ await until(flash, () => log(flash).includes('back from the dead'), 'it did not find the new port');
142
+ await flash.monitorStop();
143
+ assert.equal(flash.state.port, replugged, 'and Connect should now use the live one');
144
+ flash.remove();
145
+ });
146
+
147
+ test('does not take a different device just because one is plugged in', async () => {
148
+ const other = fakePort([{chunks: ['not the board you want\n']}],
149
+ {usbVendorId: 0x1a86, usbProductId: 0x7523}); // a CH340, not our ESP32
150
+ const flash = monitoring([{chunks: ['before\n'], end: 'error'}], () => [other], ESP32);
151
+ await until(flash, () => log(flash).includes('port gone'), 'it should be waiting');
152
+ await new Promise((r) => setTimeout(r, 40));
153
+ await flash.monitorStop();
154
+ assert.ok(!log(flash).includes('not the board you want'), 'it monitored the wrong device');
155
+ flash.remove();
156
+ });
157
+
158
+ test('never picks up a Mac\u2019s Bluetooth or debug-console port', async () => {
159
+ // Both are always on offer and neither is a board. They have no usbVendorId, which is what rules
160
+ // them out - silently monitoring one of those is worse than not reconnecting at all.
161
+ const bluetooth = fakePort([{chunks: ['bluetooth noise\n']}], {});
162
+ const debugConsole = fakePort([{chunks: ['debug console noise\n']}], {});
163
+ const flash = monitoring([{chunks: ['before\n'], end: 'error'}],
164
+ () => [bluetooth, debugConsole], ESP32);
165
+ await until(flash, () => log(flash).includes('port gone'), 'it should be waiting');
166
+ await new Promise((r) => setTimeout(r, 40)); // several more attempts, with both on offer
167
+ await flash.monitorStop();
168
+ assert.equal(bluetooth.opened, 0, 'it opened the Bluetooth port');
169
+ assert.equal(debugConsole.opened, 0, 'it opened the debug console');
170
+ flash.remove();
171
+ });
172
+
173
+ test('retries only the port the user picked when that port is not a USB device', async () => {
174
+ // Nothing to match on, so there is no safe way to guess a replacement
175
+ const other = fakePort([{chunks: ['someone else\n']}], ESP32);
176
+ const flash = monitoring([{chunks: ['before\n'], end: 'error'}], () => [other], {});
177
+ await until(flash, () => log(flash).includes('port gone'), 'it should be waiting');
178
+ await new Promise((r) => setTimeout(r, 40));
179
+ await flash.monitorStop();
180
+ assert.equal(other.opened, 0, 'it wandered off to another device');
181
+ flash.remove();
182
+ });
183
+
184
+ test('does not reset the board it reconnects to', async () => {
185
+ // The first pass pulses RTS to catch the boot log from the start; doing that on a reconnect
186
+ // would restart a board that had just woken from sleep
187
+ const flash = monitoring([
188
+ {chunks: ['boot\n'], end: 'error'},
189
+ {chunks: ['awake\n']},
190
+ ]);
191
+ await until(flash, () => log(flash).includes('awake'), 'no reconnect');
192
+ const afterFirst = flash.state.port.signals;
193
+ await flash.monitorStop();
194
+ assert.ok(afterFirst > 0, 'the first pass should have reset the board');
195
+ assert.equal(flash.state.port.signals, afterFirst, 'the reconnect should not have');
196
+ flash.remove();
197
+ });
198
+
199
+ test('says the port is gone once, not once per attempt', async () => {
200
+ const flash = monitoring([{fail: 'No such port'}]);
201
+ await until(flash, () => log(flash).includes('could not open port'), 'nothing was said');
202
+ await new Promise((r) => setTimeout(r, 40)); // several more attempts
203
+ await flash.monitorStop();
204
+ assert.equal(log(flash).match(/could not open port/g).length, 1);
205
+ assert.ok(flash.state.port.opened > 1, 'it should have kept trying');
206
+ flash.remove();
207
+ });
208
+
209
+ test('Stop ends it while it is waiting for a port that never comes back', async () => {
210
+ const flash = monitoring([{fail: 'No such port'}]);
211
+ await until(flash, () => flash.state.port.opened > 1, 'it should be retrying');
212
+ await flash.monitorStop();
213
+ assert.equal(flash.state.monitoring, false);
214
+ const opened = flash.state.port.opened;
215
+ await new Promise((r) => setTimeout(r, 30));
216
+ assert.equal(flash.state.port.opened, opened, 'it should have stopped trying');
217
+ assert.match(log(flash), /---- monitor stopped ----/);
218
+ flash.remove();
219
+ });
220
+ });
@@ -67,3 +67,24 @@ describe('lines sharing a schema colour are told apart', () => {
67
67
  assert.equal(new Set(colors).size, 3);
68
68
  });
69
69
  });
70
+
71
+ describe('an axis says what it measures', () => {
72
+ // graphdataset.graph is the route to a graph that does not need a connected element or a canvas
73
+ const axisOf = (mt, g) => { mt.graphdataset = { graph: g }; const id = mt.yaxisid; return g.state.scales[id]; };
74
+
75
+ test('the title is drawn, and carries the unit symbol', () => {
76
+ const { projectMt } = mock.runScenario('one-device');
77
+ const g = new (customElements.get('mqtt-graph'))();
78
+ const axis = axisOf(projectMt.nodes['esp8266-fb94bb'].groups.sht.topics.temperature, g);
79
+ assert.equal(axis.title.display, true);
80
+ assert.equal(axis.title.text, 'Temperature (°C)');
81
+ });
82
+
83
+ test('a reading with no unit in the schema gets a bare name', () => {
84
+ const { projectMt } = mock.runScenario('one-device');
85
+ const g = new (customElements.get('mqtt-graph'))();
86
+ const mt = projectMt.nodes['esp8266-fb94bb'].groups.sht.topics.temperature;
87
+ mt.units = undefined;
88
+ assert.equal(axisOf(mt, g).title.text, 'Temperature');
89
+ });
90
+ });