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.
- package/CLAUDE.md +6 -1
- package/admin.js +133 -20
- package/cards.js +20 -0
- package/core.js +166 -8
- package/docs/FLASH_PLAN.md +30 -0
- package/flash.js +128 -37
- package/frugaliot.css +2 -0
- package/package.json +1 -1
- package/test/flash.test.js +220 -0
- package/test/graph.test.js +21 -0
- package/test/page.test.js +121 -3
- package/test/setup.js +4 -3
package/docs/FLASH_PLAN.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
428
|
-
.catch((e) => XXX(`monitor
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
this.logLine(`---- monitor (${
|
|
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
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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(`(
|
|
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
|
|
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
|
@@ -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
|
+
});
|
package/test/graph.test.js
CHANGED
|
@@ -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
|
+
});
|