frugal-iot-client 1.3.13 → 1.3.14

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.
Files changed (49) hide show
  1. package/.claude/settings.json +12 -0
  2. package/CARDS_PLAN.md +841 -0
  3. package/CARDS_UX.md +1022 -0
  4. package/CLAUDE.md +128 -14
  5. package/FLASH_PLAN.md +250 -9
  6. package/admin.js +1377 -0
  7. package/base/boards.json +6 -4
  8. package/base/bootloader/esp32c3.bin +0 -0
  9. package/base/bootloader/esp32s2.bin +0 -0
  10. package/base/bootloader/esp32s3.bin +0 -0
  11. package/cards.js +867 -0
  12. package/core.js +2696 -0
  13. package/dashboard.js +12 -0
  14. package/dashboard_example.html +2 -2
  15. package/flash.js +609 -0
  16. package/frugaliot.css +342 -14
  17. package/graph.js +475 -0
  18. package/index-old.html +37 -0
  19. package/index.html +6 -6
  20. package/nodeview.js +433 -0
  21. package/package.json +11 -4
  22. package/test/cards.test.js +227 -0
  23. package/test/cardui.test.js +435 -0
  24. package/test/datatree.test.js +171 -0
  25. package/test/fixtures/config.json +1416 -0
  26. package/test/fixtures/regenerate.js +40 -0
  27. package/test/grid.test.js +258 -0
  28. package/test/lightdom.test.js +57 -0
  29. package/test/mock.html +113 -0
  30. package/test/mock.js +269 -0
  31. package/test/modules.test.js +48 -0
  32. package/test/oldui.test.js +40 -0
  33. package/test/page.test.js +292 -0
  34. package/test/permissions.test.js +134 -0
  35. package/test/serialize.js +49 -0
  36. package/test/setup.js +54 -0
  37. package/test/smoke.test.js +43 -0
  38. package/test/snapshots/card-control-wired-back.txt +197 -0
  39. package/test/snapshots/card-control-wired-front.txt +60 -0
  40. package/test/snapshots/card-mixed-sensors-summary.txt +14 -0
  41. package/test/snapshots/card-one-device-summary.txt +14 -0
  42. package/test/snapshots/control-wired.txt +264 -0
  43. package/test/snapshots/default-front.txt +225 -0
  44. package/test/snapshots/mixed-sensors.txt +208 -0
  45. package/test/snapshots/no-readings.txt +54 -0
  46. package/test/snapshots/one-device.txt +154 -0
  47. package/test/snapshots/unknown-module.txt +117 -0
  48. package/webcomponents.js +15 -5541
  49. package/widgets.js +760 -0
package/CLAUDE.md CHANGED
@@ -2,11 +2,34 @@
2
2
 
3
3
  JavaScript/Web Components MQTT client for the Frugal IoT project. Connects to an MQTT broker, auto-discovers nodes via discovery messages, and renders live sensor data using custom HTML elements.
4
4
 
5
- ## Main file
5
+ ## Modules
6
6
 
7
- `webcomponents.js` — the entire client lives here (~3600 lines). All web components, MQTT logic, graphing, i18n, and admin UI are defined in this one file.
7
+ The client was one file; it is now split. `core.js` imports none of the others — that is what keeps
8
+ it loadable on its own, and it is maintained by reaching the display elements **by tag name**
9
+ (`el('mqtt-bar', …)`), never by importing their classes.
8
10
 
9
- Dashboards (e.g. `dashboard_example2.html`) are thin HTML pages that import selectively from `webcomponents.js`.
11
+ | File | Imports | Holds |
12
+ |---|---|---|
13
+ | `core.js` | none | MQTT plumbing, the `MqttTopic*` data tree, `Watchdog`, i18n and `el`, `server_config`, `MqttClient`, `MqttWrapper` |
14
+ | `widgets.js` | core | `mqtt-bar`, `mqtt-text`, `mqtt-toggle`, `mqtt-gauge`, `mqtt-slider`, `mqtt-color`, `mqtt-choosetopic` — all shadow DOM |
15
+ | `graph.js` | core, widgets | `mqtt-graph`, `mqtt-graphdataset` — pulls Chart.js |
16
+ | `cards.js` | core | the card UI: `mqtt-devicecard`, `mqtt-devicegrid`, `mqtt-projectback`, `mqtt-dashboard`, the layout store |
17
+ | `nodeview.js` | core, widgets | the old node/group UI — **retires with `index-old.html`**; nothing new belongs here |
18
+ | `flash.js` | core | `mqtt-flash` — pulls esptool-js |
19
+ | `admin.js` | core | `mqtt-admin`, `mqtt-login`, `tabbed-display` |
20
+
21
+ Entry points: `index.html` → `dashboard.js` (the card UI, everything but `nodeview.js`);
22
+ `index-old.html` → `webcomponents.js` (everything, and the only remaining user of it);
23
+ `index-embedded.html` needs only `core.js` + `widgets.js` and must keep working.
24
+
25
+ Dashboards (e.g. `dashboard_example.html`) are thin HTML pages importing selectively.
26
+
27
+ ## Design documents
28
+
29
+ `CARDS_UX.md` is the design for the card UI — every decision is numbered (D-n) with its reasoning,
30
+ deferred work is L-n, and it is the place to argue with a choice rather than re-deciding it in code.
31
+ `CARDS_PLAN.md` is how it was built, including the bugs found on the way. `FLASH_PLAN.md` and
32
+ `HEADLESS_PLAN.md` cover their own areas.
10
33
 
11
34
  ## Code style
12
35
 
@@ -17,7 +40,13 @@ Dashboards (e.g. `dashboard_example2.html`) are thin HTML pages that import sele
17
40
  - Bad: `// Check if topic starts with the set path`
18
41
  - **No trailing summaries** — do not add a comment block or prose after a method explaining what it does.
19
42
  - **Getters**: prefer `get foo()` over plain property access whenever a value is derived or retrieved by traversing connected objects (e.g. `this.node.project`, `this.parentElement`, `this.mt.node.groups[this.group]`). Getters make the dependency chain explicit and let callers read `thing.project` naturally.
20
- - **Dashboard functionality**: prefer adding reusable behaviour to `webcomponents.js` over implementing it inside a custom dashboard file. If a dashboard needs to traverse the topic tree, compute a list, or fire an event, that logic belongs on the relevant class as a getter or method.
43
+ - **Dashboard functionality**: prefer adding reusable behaviour to the shared modules over implementing it inside a custom dashboard file. If a dashboard needs to traverse the topic tree, compute a list, or fire an event, that logic belongs on the relevant class as a getter or method.
44
+ - **A view must not walk the tree.** The cards read `nodeMt.frontRows`, `.summaryChips`, `.status`,
45
+ `mt.formatted` — resolved values, computed once on the data tree and testable with no DOM. A card
46
+ that starts iterating groups and topics has had logic put in the wrong place.
47
+ - **One gate, asked once.** `mt.canWrite` is checked in `renderMaybeWired` — the single point where
48
+ every widget decides between an input and a display — so a control cannot end up editable on one
49
+ screen and read-only on another. Resist per-widget permission checks.
21
50
 
22
51
  ---
23
52
 
@@ -86,11 +115,17 @@ Helper functions: `topicTwig(path)` → twig, `topicLeaf(path)` → leaf, `twigA
86
115
 
87
116
  Data-tree classes are always instantiated. UI element classes are only instantiated when not in headless mode.
88
117
 
89
- **Data-tree** (plain JS, no HTMLElement):
118
+ **Data-tree** (plain JS, no HTMLElement, all in `core.js`). This is where derived values live, and
119
+ it works with no DOM at all — which is what lets the cards run headless and be tested without a
120
+ browser.
90
121
  ```
91
- MqttTopic leaf data node — history, metadata, subscribe/publish logic
92
- (future: MqttTopicProject → MqttTopicNode → MqttTopicGroup, see HEADLESS_PLAN.md)
93
- Watchdog offline detection timer per node
122
+ MqttTopic leaf data node — history, metadata, subscribe/publish, formatted, canWrite
123
+ MqttTopicGroup one module on a node — summaryText/summaryShort roll up here, not on the element
124
+ MqttTopicGroupRelay / …Ota / …ControlHysteresis only where a reading list is the wrong shape
125
+ MqttTopicProject discovery; nodes
126
+ MqttTopicNode status, deviceConfig, frontRows, summaryChips — what a card reads
127
+ Watchdog offline detection timer per node (old UI only; the data tree derives status
128
+ from message arrival instead, with no timer)
94
129
  ```
95
130
 
96
131
  **UI elements** (extend HTMLElementExtended):
@@ -140,16 +175,32 @@ HTMLElementExtended (from html-element-extended)
140
175
 
141
176
  ### Adding a new group summary
142
177
 
143
- 1. Extend `MqttSummaryGroup`.
144
- 2. Declare `static get observedAttributes()` and the matching type arrays (`floatAttributes`, `boolAttributes`, etc.).
145
- 3. Implement `summaryText()` returning a short string.
146
- 4. Register as `mqtt-group<groupid>` — `MqttNode.addGroupFromTemplate` will pick it up automatically.
178
+ Usually nothing to do: `MqttTopicGroup.summaryText()` already returns the module's own readings,
179
+ formatted by the schema and capped at two, which suits 26 of the 33 modules. Write a subclass only
180
+ when a list of readings is the wrong shape — `relay` wants its name beside a tick, `ota` shows a key,
181
+ a control shows a rule. Then:
182
+
183
+ 1. Extend `MqttTopicGroup` in **`core.js`** (not the element — a card has no elements to ask).
184
+ 2. Implement `summaryText()`, and `summaryShort()` too if the chip form differs from the row form.
185
+ 3. Add it to `topicGroupClasses`, keyed by module id.
186
+
187
+ ### The card UI
188
+
189
+ - `mqtt-devicecard` and `mqtt-devicegrid` are **light DOM** (`HTMLElementExtendedMinimum`), so
190
+ `frugaliot.css` reaches them directly. That needs html-element-extended ≥ 0.1.7 (`renderRoot`).
191
+ - A light-DOM element has no `<slot>`, and `renderAndReplace` **clears its own children** — so the
192
+ grid renders once with `render0()` and mutates in place, and a card builds every child itself.
193
+ - Widgets in a card are pre-bound (`el.mt = mt`) but deliberately **not** registered as `mt.element`:
194
+ only one element may hold that, and binding would have a card competing with a graph, a second
195
+ card, or the old UI for the same topic. The card pushes values into its own children instead.
196
+ - Which readings a device shows comes from `config.d/schema/devices.yaml`, keyed by OTA key and
197
+ matched by prefix. `nodeMt.deviceConfig` resolves it.
147
198
 
148
199
  ---
149
200
 
150
201
  ## Headless mode
151
202
 
152
- `<mqtt-wrapper headless>` builds the full data tree without creating any DOM elements for the project/node/group/leaf UX. Dashboards subscribe to document events and create individual standalone elements on demand.
203
+ `<mqtt-wrapper headless>` builds the full data tree without creating any DOM elements for the project/node/group/leaf UX. Dashboards subscribe to document events and create individual standalone elements on demand. **The card UI runs this way** — `index.html` uses a headless wrapper for the connection, the selectors and the tree, and the cards render from it. So headless is the main path now, not a special case for custom dashboards, and anything that only works with `this.element` set is broken for the primary UI.
153
204
 
154
205
  Key points for `MqttTopic.message_received` headless path (the `else` branch when `this.element` is null):
155
206
  - Must set `this.state.value` so that getters like `usableName` and `wiredTopic` work without a DOM element.
@@ -196,7 +247,65 @@ const mt = document.querySelector('mqtt-wrapper')?.projectMt?.findTopic(this.sta
196
247
  |---|---|---|---|
197
248
  | `frugaliot:controlgroup` | `MqttGroupControlHysteresis` | `{ nodeMt, groupId, topics }` | populate node/group dropdowns |
198
249
  | `frugaliot:groupchanged` | `MqttTopic.message_received` (headless) | `{ nodeMt, groupId, groupMt, changed }` | re-run `wireUpDashboard` on wired/on state changes |
199
- | `frugaliot:topicschanged` | `MqttProject` | `{ project }` | retry wiring after late-arriving nodes |
250
+ | `frugaliot:topicschanged` | `MqttProject`, `MqttTopicProject.addNode` | `{ project }` | retry wiring, and add a card, after late-arriving nodes |
251
+ | `frugaliot:projectchanged` | `MqttWrapper.addProject` | `{ projectMt, organization, project }` | a project now exists — `topicschanged` is too late, it needs a node |
252
+ | `frugaliot:organizationchanged` | `MqttWrapper.onOrganization` | `{ organization }` | capabilities are per organization, so this precedes any project |
253
+ | `frugaliot:cardmove` / `cardmoveover` | `mqtt-devicecard` | `{ nodeId, delta }` / `{ nodeId, targetNodeId }` | the card asks to be moved; the grid decides what that means |
254
+ | `frugaliot:cardmode` | `mqtt-devicecard` | `{ nodeId, mode }` | so the grid can remember what was open |
255
+
256
+ ---
257
+
258
+ ## Traps
259
+
260
+ Each of these cost real time. They are not obvious from reading the code.
261
+
262
+ - **A page stylesheet cannot reach inside a shadow root.** `frugaliot.css` is loaded *into* each
263
+ shadow root as well as onto the page, so `.mqtt-text input` works from within but
264
+ `.fi-when input` (a light-DOM ancestor) never matches. **Custom properties are the only thing that
265
+ crosses the boundary** — set `--fi-…` outside, read it in a rule that lives inside.
266
+ - **`el()` assigns only `onclick`, `onchange` and `onsubmit`** (plus `textContent`, `style`,
267
+ `innerHTML`, `action`) as properties. **Any other function is silently put in `el.state`** and
268
+ never wired up: `el({onpointerdown: fn})` does nothing at all. Use `addEventListener`.
269
+ - **`@media (max-width: 1001px)` in `frugaliot.css`** inflates fonts and icons for the node/group UI,
270
+ where a node is one wide block. Inside a card it makes everything wider than the card. Those rules
271
+ read `var(--fi-chrome-font, …)`; the card page sets it to `1rem`. Anything new that is not
272
+ `nodeview.js` should opt out the same way. Replace them properly when `nodeview.js` goes.
273
+ - **`:host-context(details[open]) div { display: block }`** near the top of `frugaliot.css` has
274
+ specificity (0,2,2) and overrides most rules for any div in a shadow root inside an open
275
+ `<details>` — which is every widget in the old UI.
276
+ - **Assets resolve against the module, not the document**: `CssUrl` and `ImagesUrl` use
277
+ `import.meta.url`, or a page in a subdirectory 404s every stylesheet and icon.
278
+ - **Timestamps go through `nowMs()`**, never `Date.now()`, or a replayed history collapses onto one
279
+ instant and a graph has nothing to draw. `setClock()` is how tests decide what "now" is.
280
+ - **Caching will lie to you.** `/node_modules` and the client directory are served
281
+ `immutable, max-age=86400`, *and* `frugal-iot-server/public/service-worker.js` caches the libraries
282
+ cache-first at scope `/`. A stale library shows up as a null `append` inside `renderAndReplace`.
283
+ Devtools "disable cache" does not stop a service worker: unregister it.
284
+ - **Schema is mastered in `frugal-iot-server/config.d/schema/`.** Edit there, then
285
+ `scripts/copy-schema-to-examples.zsh`; `scripts/check-schema.js` verifies. Never edit a copy.
286
+
287
+ ---
288
+
289
+ ## Testing
290
+
291
+ `npm test` runs `node --test` over `test/*.test.js` — no browser, no broker. `test/setup.js` supplies
292
+ a jsdom DOM, an in-memory `localStorage`, and a resolve hook for the client's `/node_modules/…`
293
+ imports.
294
+
295
+ Messages are injected with `mqtt_deliver`, the same call the real client makes for every message, so
296
+ scenarios in `test/mock.js` drive the whole tree with nothing mocked below it. **`test/mock.html`
297
+ (served at `/dashboard/test/mock.html`) replays those scenarios in a browser** — pick one, switch
298
+ between the card UI, the old UI and the data tree, and see the messages that produced it.
299
+
300
+ Two lessons worth keeping:
301
+
302
+ - **Calling a handler directly does not test that it is wired.** `card.onPointerDown(...)` passed
303
+ against code that was never connected to the DOM. Dispatch real events.
304
+ - **Shadow content is not in `textContent` and not reachable by `card.querySelector`.** A test
305
+ asserting `querySelector('mqtt-choosetopic')` is null passes whether or not the chooser exists.
306
+
307
+ Snapshots in `test/snapshots/` record the rendered tree of both UIs. They exist so the old UI cannot
308
+ regress unnoticed; regenerate deliberately with `npm run test:update` and read the diff.
200
309
 
201
310
  ---
202
311
 
@@ -248,6 +357,11 @@ When adding a new UI string: add matching entries to all four language sections
248
357
 
249
358
  ## Running locally
250
359
 
360
+ The card UI is `index.html`, served at `/dashboard/`. The node/group UI it replaced is
361
+ `index-old.html`, linked from the project's home page. `frugal-iot-server` must be running: it serves
362
+ `/config.json`, `/node_modules`, the OTA files and the login flow, so the client cannot be opened as
363
+ a file.
364
+
251
365
  ```bash
252
366
  npm install
253
367
  # run frugal-iot-server pointing htmldir at this directory
package/FLASH_PLAN.md CHANGED
@@ -113,9 +113,16 @@ Flash mode (`qio` vs `dio`) is **not detectable** from the chip, and the bootloa
113
113
  `dio` works on every board (`qio` is merely faster), so shipping only the `dio_80m` bootloader
114
114
  removes the variable entirely.
115
115
 
116
- Pass `flashSize: "detect"` (a legal `FlashSizeValues` member) and let esptool-js patch the bootloader
117
- header from the real chip, rather than threading a detected string through for that purpose. The
118
- capacity byte is still read separately, to *choose the partition table*.
116
+ Pass `flashSize` as the **literal detected size string** (`"4MB"`), not `"detect"`. `"detect"` is a
117
+ legal `FlashSizeValues` member but *not* on this code path: `writeFlash` calls `flashSizeBytes()`,
118
+ which only parses `"KB"`/`"MB"` substrings and returns `-1` for anything else. Every file then fails
119
+ `data.length + address > -1`, so `writeFlash` throws `File 1 doesn't fit in the available flash`
120
+ before erasing or writing a single byte — a silent-looking failure with no board activity at all.
121
+ (`parseFlashSizeArg()` would likewise reject it, since `chip.FLASH_SIZES` only has `1MB`…`128MB`.)
122
+
123
+ Passing the real size is better than the other legal option, `"keep"`: `"keep"` skips the bounds check
124
+ entirely, whereas a literal size both patches the header correctly and gives a free
125
+ does-it-fit-in-flash guard on top of our own app-partition check.
119
126
 
120
127
  Pass **`flashMode: "keep"` and `flashFreq: "keep"`** rather than naming `dio`/`80m` explicitly. The
121
128
  shipped bootloaders are already built `dio_80m`, so `keep` preserves exactly the intended settings —
@@ -549,6 +556,94 @@ an iframe, the *parent* page needs `allow="serial"` on the iframe.
549
556
  | 4 | ⚡ on the OTA file list — flash a server-hosted binary | `/ota_get` route | built |
550
557
  | 5 | *(optional)* serial config hand-off, needs a firmware addition | none | not started |
551
558
 
559
+ ### The bootloader must come from the same framework as the firmware
560
+
561
+ **This was the root cause of a long debug and is the single most important thing on this page.**
562
+
563
+ Base bootloaders must be generated from the framework the firmware is *built against*, not from
564
+ whatever ESP32 core happens to be installed. Frugal IoT builds with **pioarduino's**
565
+ platform-espressif32, so:
566
+
567
+ ```bash
568
+ esptool --chip esp32c3 elf2image --flash-mode dio --flash-freq 80m --flash-size 4MB \
569
+ -o base/bootloader/esp32c3.bin \
570
+ ~/.platformio/packages/framework-arduinoespressif32-libs/esp32c3/bin/bootloader_qio_80m.elf
571
+ ```
572
+
573
+ Note it is the **`qio_80m` ELF with the header patched to DIO** — that is what pioarduino does, and the
574
+ result is byte-identical to `.pio/build/<env>/bootloader.bin`. The Arduino IDE core's `dio_80m` ELF
575
+ produces a *different* binary (18688 vs 19520 bytes) which does not work.
576
+
577
+ **The symptom is deeply misleading.** With a mismatched bootloader the board boots, the app runs, and
578
+ sensors, WiFi scanning and MQTT queuing all work — but LittleFS fails:
579
+
580
+ ```
581
+ E esp_littlefs: lfs.c:1383:error: Corrupted dir pair at {0x0, 0x1}
582
+ E esp_littlefs: mount failed, (-84)
583
+ E [LittleFS.cpp:107] format(): Formatting LittleFS failed! Error: -1
584
+ ```
585
+
586
+ Nothing points at the bootloader. Hours went into littlefs disk formats before spotting it.
587
+
588
+ **The diagnostic that finds it in seconds:** compare the ROM banner's second-stage loader sizes
589
+ between a known-good IDE flash and one of ours.
590
+
591
+ | | |
592
+ |---|---|
593
+ | after an IDE flash (works) | `load:0x3fcd5820,len:0x110c` `len:0xb54` `len:0x2f8c` |
594
+ | after ours, wrong bootloader | `load:0x3fcd5820,len:0x1010` `len:0x9f4` `len:0x2ea4` |
595
+
596
+ Different lengths mean different bootloaders. Always check this first when a provisioned board
597
+ misbehaves in any way.
598
+
599
+ ### The filesystem partition is left erased — the firmware formats it
600
+
601
+ Provisioning writes **four** regions (bootloader, partition table, otadata, app) and deliberately does
602
+ *not* touch the filesystem partition. `LittleFS.begin(true)` formats it on first boot, and the log
603
+ reads:
604
+
605
+ ```
606
+ LittleFS E esp_littlefs: ... Corrupted dir pair at {0x0, 0x1} <- expected, partition is erased
607
+ E esp_littlefs: mount failed, (-84) <- expected
608
+ [E] disableCore0WDT() <- format() running
609
+ initialization done. <- formatted and mounted
610
+ Creating:/frugal_iot ... Creating:/mqtt <- directories created
611
+ ```
612
+
613
+ Those first two error lines are normal on a freshly provisioned board and are **not** a fault.
614
+
615
+ This was not obvious. An earlier iteration shipped pre-formatted blank images because `format()` was
616
+ failing with `-1` — but that turned out to be the bootloader mismatch above, and once the bootloader
617
+ was right the firmware formatted its own partition without help. The machinery was removed. Recorded
618
+ in case it is ever needed again (an **ESP8266** would need it, since its `ESPFS.begin()` takes no
619
+ format-on-fail argument):
620
+
621
+ - pioarduino's builder uses **`littlefs-python`**, not `mklittlefs`, with
622
+ `read_size=1, prog_size=1, cache_size=block_size, lookahead_size=32, block_cycles=500, name_max=64,
623
+ disk_version=2.1`. `prog_size=1` is the critical one — other tools pad metadata commits to 128 or
624
+ 256 bytes, whose CRCs then fail to validate and produce `Corrupted dir pair`.
625
+ - `mklittlefs` 0.2.3 emits disk 2.0 with 256-byte padding; `@wasm-os/mklfs` 0.1.0 emits disk 2.1 but
626
+ `name_max` 255, over `CONFIG_LITTLEFS_OBJ_NAME_LEN=64`, with no wasm export to change it. **Neither
627
+ produces a mountable image.**
628
+ - A blank filesystem is 8 KB whatever the partition size — only the superblock pair has content.
629
+
630
+ ### Other bring-up fixes worth remembering
631
+
632
+ - **`flashSize` must be a literal size string.** `writeFlash`'s `flashSizeBytes()` only parses
633
+ `"KB"`/`"MB"`; `'detect'` becomes `-1` and every file fails its fits-in-flash check, throwing before
634
+ a single byte is written.
635
+ - **Never let a failed diagnostic read default to erasing.** `deviceInspect` originally treated an
636
+ unreadable board as "needs provisioning", which silently erased a working board.
637
+ - **`readFlash` waits `FLASH_READ_TIMEOUT` = 100 s** for its first packet. Bound every read or Connect
638
+ appears to hang.
639
+ - **`eraseAll` is silently ignored unless the stub is running** (`IS_STUB && eraseAll`).
640
+ - **`base/` is served `immutable, maxAge: 1 day`.** `boards.json` must be fetched with
641
+ `cache: 'no-cache'` and the part binaries cache-busted with its `version`, or edits are invisible
642
+ for a day and you debug a stale manifest.
643
+ - **Verify writes before resetting.** Provisioning reads the partition table back and reports whether
644
+ it matches. Reading the *filesystem* back after a boot is useless — the app rewrites block 0 on its
645
+ first mount attempt, so a post-boot readback never shows what was written. That trap cost real time.
646
+
552
647
  ### What is verified, and what still needs a board
553
648
 
554
649
  Verified without hardware: partition tables round-trip to the intended geometry (app slot identical in
@@ -557,15 +652,161 @@ files, of erased flash, and of garbage; littlefs superblock detection; `imageChi
557
652
  internally consistent and every referenced file present; base files served correctly at
558
653
  `/dashboard/base/...` and byte-identical over HTTP; `/ota_get` returns 401 unauthenticated.
559
654
 
560
- **Everything that touches a board is untested** — `writeFlash`, the provision/app-only read-back,
561
- `after("hard_reset")`, and the boot-log reopen have never run against real silicon. Treat the first
562
- run as a bring-up exercise, ideally on a board you are willing to re-flash over USB by other means.
563
- The specific things most likely to need adjustment:
655
+ Confirmed on hardware (ESP32-C3, 4 MB): ⚡ selects a server-hosted binary; `Connect board` resolves it
656
+ to `4MB (min_spiffs)`; **app-only flash writes and verifies** — 1365808 bytes (828304 compressed) at
657
+ `0x10000` in 10.8 s, followed by a successful hard reset. `calculateMD5Hash` being omitted is fine —
658
+ `writeFlash` skips verification without complaint.
659
+
660
+ Three bugs fixed during bring-up:
661
+
662
+ 1. `flashSize: "detect"` made `writeFlash` throw before touching the board (see above).
663
+ 2. Any flash attempt releases the loader, so the Flash button correctly stayed disabled but gave no
664
+ visible reason. Errors now render in the status area with a "reconnect" prompt.
665
+ 3. The boot log was hard-coded to 115200 and its read loop could not time out. `reader.read()` never
666
+ resolves on a silent port and the `while (Date.now() < deadline)` condition is only tested
667
+ *between* reads, so a quiet board hung the whole operation with no closing marker. The loop now
668
+ races each read against the deadline, always terminates, and says why it saw nothing.
669
+
670
+ ### Serial monitor
671
+
672
+ The log is a real monitor, not a fixed capture: it streams until **Stop monitor** is pressed. The
673
+ original 25-second window turned out to be an arbitrary cutoff that ended a working log mid-stream,
674
+ so the timeout now covers only the case where *nothing at all* arrives — once the first byte lands,
675
+ plain `read()` runs indefinitely and `monitorStop()` settles the pending read with `cancel()`, so no
676
+ polling is involved.
677
+
678
+ `Monitor` also works standalone, requesting a port if none is held, so a running board can be watched
679
+ without flashing it. It is stopped before `Connect` since esptool cannot open a port the monitor holds.
680
+
681
+ ### Serial monitor speed
682
+
683
+ frugal-iot's `startSerial()` picks the rate by toolchain, so the boot log needs a selector rather
684
+ than a constant:
685
+
686
+ | Build | Rate |
687
+ |---|---|
688
+ | PlatformIO | `SERIAL_BAUD`, default **460800** |
689
+ | Arduino IDE | **115200** |
690
+ | ESP8266 ROM boot messages | 74880 |
691
+
692
+ Two further reasons a boot log can be legitimately empty, both reported in the log rather than left
693
+ mysterious: the firmware only prints when built with **`ANY_DEBUG`**, and `startSerial()` deliberately
694
+ waits 5 s before its first line (hence a 25 s capture window).
564
695
 
565
- - whether `writeFlash` tolerates the omitted `calculateMD5Hash` (see Client change 7)
696
+ **Full provisioning is confirmed working** on an ESP32-C3 4 MB (LOLIN C3 Pico, `min_spiffs`): erase,
697
+ bootloader, partition table, otadata, blank filesystem and app in one pass, after which the board
698
+ mounts LittleFS, creates its `/wifi`, `/frugal_iot`, … directories and brings up the captive portal at
699
+ 192.168.4.1. App-only update is confirmed too.
700
+
701
+ Confirmed with the filesystem partition left erased: the firmware formats and mounts it itself, so no
702
+ blank-filesystem image is shipped.
703
+
704
+ Still untested:
705
+
706
+ - the **S2 and S3 bootloaders**, generated with the same recipe but never checked against a reference
707
+ build from those boards
566
708
  - whether 921600 baud is reliable on CH340 adapters, or the default should drop to 460800
567
- - whether the 20-second boot-log window is long enough to see WiFi come up
568
709
  - whether unsupported octal flash on an S3 really does surface as the capacity-byte fallback
710
+ - ESP8266 end to end (single whole image at `0x0`, no base bundle)
711
+
712
+ ---
713
+
714
+ ## Next steps
715
+
716
+ ### 1. Arduino-IDE-built firmware probably needs a different bootloader
717
+
718
+ Almost certainly yes — the Arduino IDE core's `dio_80m` ELF (18688 bytes) is a different binary from
719
+ pioarduino's `qio_80m`-with-DIO-header (19520 bytes), and that mismatch is exactly what broke
720
+ LittleFS. We currently ship only the pioarduino one.
721
+
722
+ Better than a warning comment on the page: **read the framework out of the app image.** Every ESP-IDF
723
+ app carries an `esp_app_desc_t` at offset `0x20` (24-byte image header + 8-byte segment header), magic
724
+ `0xABCD5432`, with `version`, `project_name`, `time`, `date` and `idf_ver` as fixed-width strings.
725
+ The current `firmware.bin` reports:
726
+
727
+ ```
728
+ idf_ver: v5.5.4 project_name: arduino-lib-builder date: Jun 2 2026
729
+ ```
730
+
731
+ So the flasher can parse the selected firmware, display what built it, and either pick a matching
732
+ bootloader or refuse when it doesn't match the one on offer — turning an invisible, badly-misleading
733
+ failure into an explicit refusal. Needs `idf_ver` recorded per bootloader in `boards.json`, which
734
+ means capturing it when the bootloader is harvested.
735
+
736
+ ### 2. Writing WiFi config does bring the littlefs machinery back — so don't do it that way
737
+
738
+ Pre-writing `/wifi/<ssid>` files means *building* a littlefs image, which reintroduces the whole
739
+ generator problem: disk 2.1, `name_max` 64, `prog_size` 1, and no in-browser library that can produce
740
+ that combination (`@wasm-os/mklfs` cannot set `name_max` at all).
741
+
742
+ Given how sensitive this proved, prefer **serial config hand-off**: after flashing we still hold the
743
+ port, and `System_Captive::dispatch` already consumes `topicTwig value` pairs, with
744
+ `set/wifi/<ssid>` = password being an existing convention. The firmware then writes its own files
745
+ using its own littlefs, so there is no format coupling, a list of networks is natural, and it doubles
746
+ as a re-configure tool for boards already in the field. Small firmware addition; no client-side
747
+ filesystem generation.
748
+
749
+ **Reuse the intake the node already has.** The firmware does not need a new way to *apply* config —
750
+ only a new way to *receive* it. Today the same values arrive over HTTP: the captive portal builds a
751
+ form from `addString` / `addNumber` / `addBool` / `addButton`, and `addSTARoute` registers POST
752
+ handlers on the station interface, so config comes in as query/form key-value pairs, is turned into
753
+ `topicTwig` + value, and goes through `dispatch()` to `writeConfigToFS()`. MQTT `set/...` messages
754
+ land in the same place.
755
+
756
+ So the work is a **transport**, not a mechanism: read the same key-value pairs from USB serial and
757
+ feed them into that existing dispatch. That keeps one code path for HTTP, MQTT and USB, and means
758
+ anything configurable through the captive portal is automatically configurable at flash time. Worth
759
+ settling on the serial line format (a `topicTwig value` pair per line is enough, terminated so a
760
+ partial line is never applied) and whether the node echoes each accepted setting so the flasher can
761
+ confirm rather than assume.
762
+
763
+ ### 3. Monitor needs Clear and Copy buttons
764
+
765
+ The log is hard to work with once long. Two buttons beside Monitor, two new strings in four languages.
766
+
767
+ ### 4. `fs.cpp` reports a scary failure that isn't one
768
+
769
+ On a freshly provisioned board the normal path prints `Corrupted dir pair`, `mount failed (-84)` and a
770
+ `disableCore0WDT` error before quietly succeeding — and `"initialization done."` is gated behind
771
+ `SYSTEM_LITTLEFS_DEBUG`, so an ordinary build shows only the alarming half. Worth fixing in
772
+ `System_LittleFS::pre_setup`: suppress the component's log level around `begin(true)` (e.g.
773
+ `esp_log_level_set("esp_littlefs", ESP_LOG_NONE)`, restoring afterwards) and print one clear line for
774
+ each outcome, with the failure case *not* gated behind a debug flag. Firmware repo change.
775
+
776
+ ### 5. ESP8266 end to end
777
+
778
+ Single whole image at `0x0`, no base bundle. Note it cannot format its own filesystem — its
779
+ `ESPFS.begin()` takes no format-on-fail argument — so this is the one case that may genuinely need the
780
+ pre-formatted blank image described above.
781
+
782
+ ### 6. S2 and S3 bootloaders
783
+
784
+ Generated with the C3 recipe but never checked against a reference build. The check is exactly what
785
+ settled the C3: build the firmware for an S2/S3 env and compare `.pio/build/<env>/bootloader.bin`
786
+ against `base/bootloader/esp32s{2,3}.bin`.
787
+
788
+ ### 7. Auto-detect chip type and refuse a binary built for the wrong chip
789
+
790
+ Partly built already: `appCheck()` compares the app image's `chip_id` (u16 at byte 12) against
791
+ `esploader.chip.IMAGE_CHIP_ID` and throws on mismatch. But it has never been exercised — every test so
792
+ far used a correctly matched C3 image — so it needs a deliberate wrong-chip attempt to confirm the
793
+ refusal fires, reads clearly, and leaves the board untouched. Also worth checking whether esptool-js
794
+ rejects it independently, so we know whether our check is the only guard or a second one.
795
+
796
+ Two related refusals in the same code path are likewise untested: an app larger than the app
797
+ partition, and a `<chip>-<flashsize>` combination absent from `boards.json`.
798
+
799
+ ### 8. UI polish
800
+
801
+ Nothing structural — the flow works but is a little unintuitive. Known rough edges:
802
+
803
+ - `Flash` is disabled until *both* a board and a firmware are chosen, with no hint saying so; the
804
+ reason should be visible rather than inferred from a grey button.
805
+ - Provision vs app-only is auto-detected and reported in prose, but the destructive case deserves to
806
+ read as clearly destructive.
807
+ - A hard reload silently clears the chosen firmware, which is surprising mid-session.
808
+ - Ordering of the controls (file, speed, monitor speed, Connect, Flash, Monitor) has grown by
809
+ accretion rather than by how it is actually used.
569
810
 
570
811
  ---
571
812