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.
- package/.claude/settings.json +12 -0
- package/CARDS_PLAN.md +841 -0
- package/CARDS_UX.md +1022 -0
- package/CLAUDE.md +128 -14
- package/FLASH_PLAN.md +250 -9
- package/admin.js +1377 -0
- package/base/boards.json +6 -4
- package/base/bootloader/esp32c3.bin +0 -0
- package/base/bootloader/esp32s2.bin +0 -0
- package/base/bootloader/esp32s3.bin +0 -0
- package/cards.js +867 -0
- package/core.js +2696 -0
- package/dashboard.js +12 -0
- package/dashboard_example.html +2 -2
- package/flash.js +609 -0
- package/frugaliot.css +342 -14
- package/graph.js +475 -0
- package/index-old.html +37 -0
- package/index.html +6 -6
- package/nodeview.js +433 -0
- package/package.json +11 -4
- package/test/cards.test.js +227 -0
- package/test/cardui.test.js +435 -0
- package/test/datatree.test.js +171 -0
- package/test/fixtures/config.json +1416 -0
- package/test/fixtures/regenerate.js +40 -0
- package/test/grid.test.js +258 -0
- package/test/lightdom.test.js +57 -0
- package/test/mock.html +113 -0
- package/test/mock.js +269 -0
- package/test/modules.test.js +48 -0
- package/test/oldui.test.js +40 -0
- package/test/page.test.js +292 -0
- package/test/permissions.test.js +134 -0
- package/test/serialize.js +49 -0
- package/test/setup.js +54 -0
- package/test/smoke.test.js +43 -0
- package/test/snapshots/card-control-wired-back.txt +197 -0
- package/test/snapshots/card-control-wired-front.txt +60 -0
- package/test/snapshots/card-mixed-sensors-summary.txt +14 -0
- package/test/snapshots/card-one-device-summary.txt +14 -0
- package/test/snapshots/control-wired.txt +264 -0
- package/test/snapshots/default-front.txt +225 -0
- package/test/snapshots/mixed-sensors.txt +208 -0
- package/test/snapshots/no-readings.txt +54 -0
- package/test/snapshots/one-device.txt +154 -0
- package/test/snapshots/unknown-module.txt +117 -0
- package/webcomponents.js +15 -5541
- 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
|
-
##
|
|
5
|
+
## Modules
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
561
|
-
`
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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
|
|