frugal-iot-client 1.3.11 → 1.3.13

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.
@@ -22,7 +22,10 @@
22
22
  "Bash(curl *)",
23
23
  "Bash(grep -n \"el\\('p', {}, \\\\[\\\\\"\" webcomponents.js)",
24
24
  "Bash(grep -n \"el\\('h[1-5]', {}, \\\\[\\\\\"\" webcomponents.js)",
25
- "Bash(grep -n \"^const languages\\\\|^EN:\\\\|^FR:\\\\|^HI:\\\\|^ID:\\\\|^\\\\`\\);\" /Users/mitra/git/github_mitra42/frugal-iot-client/webcomponents.js)"
25
+ "Bash(grep -n \"^const languages\\\\|^EN:\\\\|^FR:\\\\|^HI:\\\\|^ID:\\\\|^\\\\`\\);\" /Users/mitra/git/github_mitra42/frugal-iot-client/webcomponents.js)",
26
+ "WebFetch(domain:raw.githubusercontent.com)",
27
+ "Read(//Users/mitra/git/github_mitra42/**)",
28
+ "Read(//Users/mitra/git/github_mitra42/frugal-iot-server/**)"
26
29
  ]
27
30
  }
28
31
  }
package/FLASH_PLAN.md ADDED
@@ -0,0 +1,584 @@
1
+ # Plan: Flash a board over USB from the OTA tab
2
+
3
+ ## Goal
4
+
5
+ Let a user plug an ESP board into their laptop and flash it directly from the OTA tab — both a
6
+ *virgin* board (bootloader + partition table + otadata + app) and an *already-provisioned* board
7
+ (app only, preserving its LittleFS config). The app image is either a local `.bin` or one of the
8
+ files already listed in the OTA tab.
9
+
10
+ This complements OTA rather than replacing it: OTA handles boards that are already on WiFi and
11
+ talking to the broker; USB flashing handles first provisioning and recovery of boards that can't be
12
+ reached.
13
+
14
+ ---
15
+
16
+ ## Library
17
+
18
+ [`esptool-js`](https://github.com/espressif/esptool-js) — Espressif's official JS port of
19
+ `esptool.py`, Apache-2.0. Drives the ESP ROM bootloader over the Web Serial API.
20
+
21
+ Use **`node_modules/esptool-js/bundle.js`**, not the package's default `lib/index.js` entry:
22
+
23
+ | | `lib/index.js` | `bundle.js` |
24
+ |---|---|---|
25
+ | Bare imports | `pako`, `tslib`, `atob-lite` | none — all inlined |
26
+ | CommonJS deps | `atob-lite` is CJS, breaks in a browser ESM context | n/a |
27
+ | Network calls | none | none — flasher stubs inlined as base64 |
28
+ | Size | many files | one file, ~214 KB |
29
+
30
+ So it fits the existing no-build-step, import-map setup with a single line. Exports used:
31
+ `ESPLoader`, `Transport`.
32
+
33
+ ### Browser support
34
+
35
+ | Constraint | Consequence |
36
+ |---|---|
37
+ | Web Serial = Chrome/Edge/Opera ≥89, desktop only | Feature-detect `'serial' in navigator`; render an explanation instead of the UI when absent. No Firefox, no Safari, no iOS — ever. |
38
+ | Requires a secure context | Already satisfied (`https://localhost:8080` in dev, HTTPS in production). |
39
+ | Requires a user gesture | `navigator.serial.requestPort()` must be called synchronously in the click handler, before any `await`. |
40
+ | Android Chrome | Only via `web-serial-polyfill` (WebUSB), and it fails on CH340 adapters because the kernel driver claims the device. Not supported in v1. |
41
+
42
+ ---
43
+
44
+ ## The app-only vs. full-image problem
45
+
46
+ The `.bin` files the OTA tab already handles are **application images**, not complete flash images:
47
+
48
+ - **ESP8266** — Arduino's `firmware.bin` *is* the whole image (eboot included). Flash at `0x0`. Works
49
+ on a virgin chip. None of the "base" machinery below applies.
50
+ - **ESP32 family** — app-only. Needs a bootloader, partition table and otadata already present, or it
51
+ will not boot.
52
+
53
+ So an ESP32 board needs three extra regions that are **independent of your sketch**:
54
+
55
+ | Piece | Depends on | Per-app? |
56
+ |---|---|---|
57
+ | `bootloader.bin` | chip + flash mode/freq | No |
58
+ | `partitions.bin` | partition scheme (`min_spiffs`, `frugal_8mb`, …) | No |
59
+ | `boot_app0.bin` | nothing — fixed 8192-byte blob | No |
60
+ | `firmware.bin` | your app | Yes — this is what the OTA tab already stores |
61
+
62
+ Call those three a **base bundle**. Harvest once per chip, reuse for every app build.
63
+
64
+ ### Why the base can't be generated in the browser
65
+
66
+ Both Arduino core 3.3.11 and PlatformIO ship the bootloader as **`.elf` only** — e.g.
67
+ `~/Library/Arduino15/packages/esp32/tools/esp32c3-libs/3.3.11/bin/bootloader_dio_80m.elf` and
68
+ `~/.platformio/packages/framework-arduinoespressif32-libs/esp32c3/bin/` — and convert to `.bin` with
69
+ `esptool elf2image` at build time. Only `default.bin` (3072 bytes) and `boot_app0.bin` (8192 bytes)
70
+ ship prebuilt; other partition schemes are generated from `.csv` by `gen_esp32part.py` during the
71
+ build.
72
+
73
+ **esptool-js deliberately does not implement `elf2image`.** So base binaries must be harvested from a
74
+ completed build and committed, not built on demand.
75
+
76
+ Where to find them after a build:
77
+
78
+ | Toolchain | bootloader | partition table |
79
+ |---|---|---|
80
+ | PlatformIO | `.pio/build/<env>/bootloader.bin` | `.pio/build/<env>/partitions.bin` |
81
+ | Arduino IDE | `build/<board>/frugal-iot.ino.bootloader.bin` | `build/<board>/frugal-iot.ino.partitions.bin` |
82
+
83
+ `boot_app0.bin` comes straight from the core: `tools/partitions/boot_app0.bin`.
84
+
85
+ Total per chip: bootloader ~20–26 KB + partitions 3 KB + boot_app0 8 KB ≈ **35 KB**. Small enough to
86
+ commit.
87
+
88
+ ### The bundle matrix collapses — no chip×size product
89
+
90
+ This matters because the boards belong to *users of this project*, not to us, so the set of flash
91
+ sizes in the wild is open-ended. Fortunately the three base parts vary along different axes, and none
92
+ of them along both:
93
+
94
+ | Part | Varies by | Files needed |
95
+ |---|---|---|
96
+ | `bootloader.bin` | chip **only** — *not* flash size | one per chip (3) |
97
+ | `boot_app0.bin` | nothing | **one, globally** |
98
+ | `partitions.bin` | partition scheme **only** — chip-independent | one per scheme |
99
+
100
+ - The bootloader is flash-size-independent because esptool patches the flash size/mode/freq bytes into
101
+ the header of whatever image is written at the bootloader offset (`updateImageFlashParams` in the
102
+ bundle). Corroboration: the Arduino core ships exactly four bootloader ELFs per chip
103
+ (`dio`/`qio` × `40m`/`80m`) and uses them across all board variants regardless of flash size.
104
+ - `partitions.bin` is chip-independent because `gen_esp32part.py` output is just the table — no chip
105
+ fields. A `min_spiffs` table is byte-identical for C3 and S3.
106
+
107
+ So supporting a new flash size means adding **one** `partitions.bin`, with no chip-specific work, and
108
+ supporting a new chip means adding **one** `bootloader.bin`.
109
+
110
+ ### Ship `dio` bootloaders only
111
+
112
+ Flash mode (`qio` vs `dio`) is **not detectable** from the chip, and the bootloader is built per mode.
113
+ `dio` works on every board (`qio` is merely faster), so shipping only the `dio_80m` bootloader
114
+ removes the variable entirely.
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*.
119
+
120
+ Pass **`flashMode: "keep"` and `flashFreq: "keep"`** rather than naming `dio`/`80m` explicitly. The
121
+ shipped bootloaders are already built `dio_80m`, so `keep` preserves exactly the intended settings —
122
+ and it matters on ESP8266, where the *application* image sits at the bootloader offset and so would
123
+ itself be rewritten with mode/frequency settings its flash may not support. Only the size gets
124
+ patched.
125
+
126
+ ### Octal (OPI) flash is not supported — detect and refuse
127
+
128
+ `FlashModeValues` is `"keep" | "dio" | "qio" | "dout" | "qout"` — there is **no `opi`**. So ESP32-S3
129
+ modules with octal flash (ESP32-S3-WROOM-2, 16/32 MB) cannot be flashed by esptool-js in their native
130
+ mode. This is the one real S3 gap; flash *size* detection is fine (see below).
131
+
132
+ These boards must be identified and refused with an explanatory message rather than half-flashed into
133
+ a dead state. A practical signal is `detectFlashSize()` landing on its unrecognized-capacity fallback
134
+ on an S3 — OPI flash not answering a standard RDID in default SPI mode is the likely cause. This
135
+ needs confirming against a real WROOM-2 before the message is written as a certainty.
136
+
137
+ ---
138
+
139
+ ## No board dropdown — detect the chip
140
+
141
+ esptool-js supplies everything needed to pick the base without asking the user:
142
+
143
+ | What | How |
144
+ |---|---|
145
+ | Chip | `esploader.chip.CHIP_NAME` after `await esploader.main()` → `"ESP32-C3"` |
146
+ | Flash size | `await esploader.detectFlashSize()` → reads the JEDEC capacity byte (`readFlashId() >> 16 & 0xFF`) against `DETECTED_FLASH_SIZES` |
147
+ | Bootloader offset | `esploader.chip.BOOTLOADER_FLASH_OFFSET` → `0x0` / `0x1000` / `0x2000` |
148
+ | Extra display info | `getChipDescription()`, `getChipRevision()`, `getFlashVendor()`, `getPsramCap()` |
149
+
150
+ Base selection is therefore `f(chip, flashSize)`. Pin variants (`ESP8266_D1`,
151
+ `ARDUINO_LOLIN_C3_PICO`, `ARDUINO_LOLIN_S2_MINI`, S3T3 …) are irrelevant here — the bootloader and
152
+ partition table depend only on chip, flash mode/freq and partition scheme. Board differences are
153
+ already covered by *which OTA file the user picks*.
154
+
155
+ ### Flash-size detection is chip-agnostic, including S3
156
+
157
+ `DETECTED_FLASH_SIZES` is defined **once** in the whole bundle, on `ESPLoader` itself rather than on
158
+ any ROM class — so an S3 takes exactly the same path as a C3. The table maps the JEDEC capacity byte:
159
+
160
+ ```
161
+ 0x16 → 4MB 0x17 → 8MB 0x18 → 16MB 0x19 → 32MB 0x1A → 64MB 0x1B → 128MB
162
+ ```
163
+
164
+ plus the `0x20–0x22` and `0x32–0x3A` alternate vendor encodings. Every realistic S3 board (4/8/16/32
165
+ MB) reports correctly, so the tool can pick the right partition table without asking the user which
166
+ board they have.
167
+
168
+ `getFlashCap()` (eFuse `EFUSE_BLOCK1 + 12`, bits 27–29) is available as a secondary cross-check, but
169
+ it reports *in-package* flash capacity only and is meaningless for modules with external flash — so
170
+ it is a sanity check, not the primary source.
171
+
172
+ **One trap:** `detectFlashSize()` falls back to `"4MB"` with only a log line when the capacity byte
173
+ is unrecognized. That is exactly the case that would brick a 2 MB board — and on an S3 it is also the
174
+ likely signature of unsupported octal flash. Always display the detected size, and when it came from
175
+ the fallback rather than a real table match, require explicit confirmation instead of proceeding.
176
+
177
+ Because distinguishing a real 4 MB from the fallback 4 MB requires the table, and because reading a
178
+ property off a minified bundle is a fragile dependency, the client keeps its own copy of the
179
+ capacity-byte map (`DetectedFlashSizes`) and derives both the size and the *was-it-guessed* flag from
180
+ that. It is a stable JEDEC convention, so the duplication is cheap.
181
+
182
+ ---
183
+
184
+ ## Deciding provision vs. app-only automatically
185
+
186
+ This can be decided with high confidence by reading the flash back before writing — `esploader`
187
+ exposes `readFlash()`, and with the stub loaded, reading ~7 KB is fast.
188
+
189
+ 1. Read 3 KB at `0x8000`. Absent partition-table magic (`AA 50`) → no valid layout → **provision**.
190
+ 2. Parse the on-device table and check the **app slot only** — `app0` at `0x10000`, size `0x1E0000`.
191
+ Differs → the board is laid out for something else → **provision**.
192
+ 3. Read 4 bytes at `chip.BOOTLOADER_FLASH_OFFSET`. Not `0xE9` → **provision**.
193
+ 4. Read the first 4 KB of the on-device FS partition (offset from *its* table, not ours) and look for
194
+ the littlefs superblock magic — the ASCII string `littlefs`. Present → a filesystem exists worth
195
+ preserving → **app-only**.
196
+
197
+ Comparing only the app slot rather than the whole table is deliberate, and it's what the fixed app
198
+ geometry buys: a board whose FS partition is a different size than our scheme would produce is still
199
+ perfectly safe to update app-only. That correctly covers a board provisioned by an earlier version of
200
+ this tool, or flashed by hand from PlatformIO with stock `min_spiffs`, instead of needlessly wiping
201
+ its config.
202
+
203
+ That resolves the "is it virgin?" question definitively. The one residue is step 4's blind spot: a
204
+ matching partition table plus a valid LittleFS **belonging to a different project**. Flash contents
205
+ alone can't distinguish that without mounting the filesystem in the browser (see the LittleFS option
206
+ below, which this plan does not adopt).
207
+
208
+ The mitigation is to make the wrong guess cheap rather than to chase certainty:
209
+
210
+ - Default to **app-only** in that case and say so plainly in the UI ("existing config found — will be
211
+ preserved"), with a **Provision instead (erases config)** button beside it.
212
+ - Stream the serial boot log after reset (see below). If the board comes up with the wrong
213
+ project/WiFi, the user hits Provision and loses ten seconds.
214
+
215
+ So: automatic, with the residual ambiguity surfaced rather than guessed, and a one-click recovery.
216
+
217
+ ---
218
+
219
+ ## Writing config at flash time
220
+
221
+ The firmware keeps config in **LittleFS, not NVS** — `src/system/fs.h` does `#define ESPFS LittleFS`,
222
+ and the layout is `data/wifi/<ssid>` (content = password) plus `data/frugal_iot/`. So the partition
223
+ to pre-write would be the filesystem.
224
+
225
+ It is also **already solved without the flasher**: `src/system/captive.cpp` is a captive portal whose
226
+ "main use is for setting the WiFi settings", with `addString`/`addNumber`/`addBool` over arbitrary
227
+ `topicTwig`s. Pre-writing config is a convenience for bulk provisioning, not a requirement.
228
+
229
+ Three routes, in the order this plan prefers them:
230
+
231
+ 1. **Captive portal (v1 — no new code).** Flash, then the user joins the board's AP and configures it.
232
+ Works today.
233
+ 2. **Serial hand-off (v2 — small firmware addition).** We still hold the serial port after flashing.
234
+ Keep it open and let the firmware accept `topicTwig value` pairs on serial —
235
+ `System_Captive::dispatch` already consumes that shape, so it reuses existing plumbing. Bonus: it
236
+ becomes a *re-configure* tool, not just a provision-time one.
237
+ 3. **Build a LittleFS image in the browser (not adopted).**
238
+ [`littlefs-project/littlefs-js`](https://github.com/littlefs-project/littlefs-js) (MIT, emscripten
239
+ build of upstream littlefs) or [`hurzhurz/littlefs-image-creator`](https://hurzhurz.github.io/littlefs-image-creator/)
240
+ (`lfs.js`, 193 KB, license unstated on the repo). Neither is on npm, so this means committing a
241
+ ~200 KB wasm/asm.js blob, and it couples the client to the firmware's exact geometry: block size
242
+ 4096, block count = FS partition size / 4096 (`0x20000` = 128 KB under `min_spiffs`, so only 32
243
+ blocks), and the littlefs *on-disk version* — a v2.0/v2.1 mismatch presents as an unformatted
244
+ filesystem, silently wiping the config just written. Not worth it to replace a working captive
245
+ portal; revisit only if high-volume provisioning makes the AP-join step the bottleneck.
246
+
247
+ **Worth building regardless:** stream the serial boot log into the flasher UI after reset. We own the
248
+ port already, it confirms the flash actually booted, and it shows the AP name to join.
249
+
250
+ ---
251
+
252
+ ## Base bundles
253
+
254
+ Committed to this repo and served by the existing static handler. Because the parts vary along
255
+ independent axes (see above), they are stored **de-duplicated** rather than as a chip×size matrix:
256
+
257
+ ```
258
+ base/
259
+ boards.json
260
+ boot_app0.bin 8 KB — one file for everything
261
+ bootloader/
262
+ esp32c3.bin ~24 KB — dio_80m, any flash size
263
+ esp32s2.bin
264
+ esp32s3.bin
265
+ partitions/
266
+ min_spiffs.bin 3 KB — chip-independent (Arduino stock, 4 MB)
267
+ min_spiffs.csv — provenance for the above
268
+ frugal_8mb.bin — custom, see below
269
+ frugal_8mb.csv
270
+ frugal_16mb.bin
271
+ frugal_16mb.csv
272
+ ```
273
+
274
+ Target chip set: **ESP8266** (no base needed at all), **ESP32-C3**, **ESP32-S2**, **ESP32-S3**.
275
+ `min_spiffs` for the smaller chips, per Frugal IoT convention.
276
+
277
+ ### Partition schemes: fixed app size, all spare space to LittleFS
278
+
279
+ Bigger boards get the *same* app partitions as a 4 MB board, with the surplus going to the
280
+ filesystem. Arduino's stock large-flash schemes do the opposite (`default_8MB` gives 3.1 MB app
281
+ slots and only 1.5 MB of FS), so these are custom tables.
282
+
283
+ Starting from stock `min_spiffs` (4 MB), which is unchanged:
284
+
285
+ | Partition | Type/Subtype | Offset | `min_spiffs` (4 MB) | `frugal_8mb` | `frugal_16mb` |
286
+ |---|---|---|---|---|---|
287
+ | `nvs` | data/nvs | `0x9000` | `0x5000` | `0x5000` | `0x5000` |
288
+ | `otadata` | data/ota | `0xe000` | `0x2000` | `0x2000` | `0x2000` |
289
+ | `app0` | app/ota_0 | `0x10000` | `0x1E0000` | `0x1E0000` | `0x1E0000` |
290
+ | `app1` | app/ota_1 | `0x1F0000` | `0x1E0000` | `0x1E0000` | `0x1E0000` |
291
+ | `spiffs` | data/spiffs | `0x3D0000` | `0x20000` (128 KB) | `0x420000` (4.125 MB) | `0xC20000` (12.125 MB) |
292
+ | `coredump` | data/coredump | *varies* | `0x3F0000` | `0x7F0000` | `0xFF0000` |
293
+
294
+ App partitions stay 64 KB-aligned and the FS partition stays 4 KB-aligned in all three, and each
295
+ table sums exactly to its flash size.
296
+
297
+ **Two consequences worth relying on:**
298
+
299
+ 1. **The app image is flash-size-independent.** `app0` has the same offset *and* size in every
300
+ scheme, so one `firmware.bin` flashes to a 4, 8 or 16 MB board. The OTA files in the tab need no
301
+ per-size variants, and the app-only path always writes at `0x10000`.
302
+ 2. **A 1.875 MB app slot is generous** and unchanged from what 4 MB boards already run, so nothing
303
+ about existing builds needs revisiting.
304
+
305
+ **Keep the label `spiffs`, even though the content is LittleFS.** Arduino's LittleFS mounts by
306
+ partition label, defaulting to `"spiffs"`
307
+ (`LittleFS.h`: `begin(bool formatOnFail = false, const char *basePath = "/littlefs", uint8_t maxOpenFiles = 10, const char *partitionLabel = "spiffs")`).
308
+ Renaming the partition to `littlefs` would stop the firmware mounting it unless `begin()` is also
309
+ changed. Not worth the coupling.
310
+
311
+ These CSVs are Frugal IoT's own, so they belong in the **firmware** repo (so builds can reference them
312
+ via `board_build.partitions`), with the generated `.bin` plus a copy of the `.csv` committed here for
313
+ the flasher. Generate with the core's `tools/gen_esp32part.py`, or just harvest
314
+ `.pio/build/<env>/partitions.bin` from a build configured with that CSV.
315
+
316
+ `boards.json` maps a detected `<chip>-<flashsize>` to the parts to use, so lookup after detection is
317
+ a single key:
318
+
319
+ ```json
320
+ {
321
+ "schemeByChipAndSize": {
322
+ "ESP32-C3-4MB": "min_spiffs",
323
+ "ESP32-S2-4MB": "min_spiffs",
324
+ "ESP32-S3-4MB": "min_spiffs",
325
+ "ESP32-S3-8MB": "frugal_8mb",
326
+ "ESP32-S3-16MB": "frugal_16mb"
327
+ },
328
+ "bootloaderByChip": {
329
+ "ESP32-C3": "bootloader/esp32c3.bin",
330
+ "ESP32-S2": "bootloader/esp32s2.bin",
331
+ "ESP32-S3": "bootloader/esp32s3.bin"
332
+ },
333
+ "otadata": "boot_app0.bin",
334
+ "coreVersion": "esp32-3.3.11",
335
+ "version": 1
336
+ }
337
+ ```
338
+
339
+ Adding a flash size is one line here plus one `partitions/*.bin`. An unlisted `<chip>-<flashsize>`
340
+ combination is a clean "unsupported board" message, not a guess.
341
+
342
+ The three regions to write, with offsets derived rather than hardcoded:
343
+
344
+ - `bootloader` → `esploader.chip.BOOTLOADER_FLASH_OFFSET`
345
+ - `partitions` → `0x8000`
346
+ - `otadata` → **parsed out of `partitions.bin`**
347
+
348
+ ### Parse the partition table
349
+
350
+ `partitions.bin` is a flat array of 32-byte records: magic `AA 50` (2), type (1), subtype (1),
351
+ offset (u32 LE), size (u32 LE), 16-byte label, flags (u32). Type `0x00` = app (subtype `0x00`
352
+ factory, `0x10` ota_0), type `0x01` = data (subtype `0x00` otadata, `0x02` nvs, `0x83` littlefs).
353
+
354
+ ~30 lines of parsing yields:
355
+
356
+ - the **otadata** offset → where `boot_app0.bin` goes
357
+ - the **app slot** offset and size → where the app goes and how large it may be
358
+ - the **FS slot** offset and size → what to probe in step 4 of the provision/update decision
359
+
360
+ So the app's flash address is data-driven from the selected base, never a hardcoded `0x10000`, and
361
+ the "does this app fit?" check comes free.
362
+
363
+ ### Record the core version
364
+
365
+ An Arduino-core 3.x bootloader boots 3.x apps fine, but crossing a major core version can require
366
+ re-provisioning. `coreVersion` in `boards.json` makes a stale base visible rather than mysterious,
367
+ and needs refreshing when the firmware's core is bumped.
368
+
369
+ ### Contributing a new board
370
+
371
+ Since the boards belong to users of this project, document the harvest path so a contributor can add
372
+ support without touching the client code:
373
+
374
+ 1. Build the firmware for the board.
375
+ 2. If the **chip** is new, copy `bootloader.bin` to `base/bootloader/<chip>.bin`.
376
+ 3. If the **flash size** is new, write a CSV following the table above — same app partitions, all
377
+ surplus to `spiffs` — and commit both it and the generated `.bin` to `base/partitions/`.
378
+ 4. Add one `"<CHIP>-<SIZE>": "<scheme>"` line to `schemeByChipAndSize`.
379
+
380
+ `boot_app0.bin` never needs touching, and the app binary never needs rebuilding for a different flash
381
+ size.
382
+
383
+ ---
384
+
385
+ ## Refuse-before-writing checks
386
+
387
+ All four are **refusals, not warnings** — a bad combination here means a board that doesn't boot.
388
+
389
+ 1. Detected chip must match the base's `chip`.
390
+ 2. App image: byte 0 == `0xE9`, and for ESP32-family images `chip_id` (u16 LE at offset 12) ==
391
+ `esploader.chip.IMAGE_CHIP_ID` (0=ESP32, 2=S2, 5=C3, 9=S3, 12=C2, 13=C6, 16=H2 — the ROM classes
392
+ expose it). This catches the classic "C3 build onto a D1 Mini".
393
+ 3. App byte length ≤ app-slot size from the parsed partition table. Catches a `min_spiffs` app
394
+ dropped onto a `default`-scheme board.
395
+ 4. `detectFlashSize()` must produce a `<chip>-<flashsize>` key present in `schemeByChipAndSize` — and
396
+ if the size came from the `"4MB"` fallback, require confirmation (and on an S3, warn about octal
397
+ flash).
398
+
399
+ ---
400
+
401
+ ## Client changes
402
+
403
+ ### 1. Dependency and import maps
404
+
405
+ `npm i esptool-js`, then add to the import map in **all five** pages that load `webcomponents.js`:
406
+
407
+ ```json
408
+ "esptool-js": "/node_modules/esptool-js/bundle.js"
409
+ ```
410
+
411
+ - `index.html`, `index-project.html`, `login.html`, `dashboard_example.html` — relative path as above.
412
+ - `index-embedded.html` — use the absolute form to match its existing entries:
413
+ `https://frugaliot.naturalinnovation.org/node_modules/esptool-js/bundle.js`.
414
+
415
+ Load it **lazily** — `await import('esptool-js')` inside the click handler — so 214 KB never hits a
416
+ plain dashboard page load.
417
+
418
+ ### 2. New `MqttFlash` element in `webcomponents.js`
419
+
420
+ Per CLAUDE.md this belongs in the main file, not a dashboard. It has no MQTT topic, so it extends
421
+ `HTMLElementExtended` directly rather than `MqttReceiver`.
422
+
423
+ Renders: a **Connect** button, a detected-hardware line, an app-source selector (local file or one of
424
+ the listed OTA files), a **Flash** button, a progress bar, and a `<pre>` log driven by esptool-js's
425
+ `IEspLoaderTerminal` (`{clean, write, writeLine}`) and then by the post-reset boot log.
426
+
427
+ State refs go in `this.state.elements` and update in place, per the existing pattern.
428
+
429
+ ### 3. Wire it into the OTA tab
430
+
431
+ Add a `el('section', …)` to `otaRestContent()` ([webcomponents.js:3086](webcomponents.js#L3086)),
432
+ after the existing upload form, guarded on `'serial' in navigator`.
433
+
434
+ ### 4. Add ⚡ to the OTA file list
435
+
436
+ `otaFilesList()` ([webcomponents.js:2244](webcomponents.js#L2244)) lists directories (`myproject/node42`,
437
+ `+/esp32c3`), each holding a `firmware.bin`. Add a ⚡ pseudolink beside the existing 🗑 that selects
438
+ that file as the flash source.
439
+
440
+ Fetching the bytes needs `fetch(url)` → `new Uint8Array(await res.arrayBuffer())`. `GET()` from
441
+ html-element-extended parses JSON and won't do here.
442
+
443
+ ### 5. Flash sequence
444
+
445
+ ```javascript
446
+ const port = await navigator.serial.requestPort(); // synchronous in the click handler
447
+ const transport = new Transport(port, true);
448
+ const esploader = new ESPLoader({transport, baudrate: 921600, terminal});
449
+ const chip = await esploader.main(); // detect + return chip name
450
+ const flashSize = await esploader.detectFlashSize();
451
+ // … pick base, read back flash, decide provision vs app-only, run refusal checks …
452
+ await esploader.writeFlash({
453
+ fileArray, // [{data: Uint8Array, address: number}, …]
454
+ flashSize: 'detect', flashMode: 'dio', flashFreq: '80m',
455
+ eraseAll: provisioning, compress: true, reportProgress,
456
+ });
457
+ await esploader.after(); // hard reset into the new app
458
+ // … stream boot log …
459
+ await transport.disconnect();
460
+ ```
461
+
462
+ `fileArray.data` is a **`Uint8Array`** — older 0.4.x tutorials pass a binary string; that API is
463
+ gone. A full provision is one `writeFlash` call with four entries, so the user connects once and the
464
+ board resets once.
465
+
466
+ ### 6. Baud rate
467
+
468
+ Start at 921600. CH340-based D1 Minis are often happier at 460800 — make it a dropdown if real
469
+ hardware proves flaky.
470
+
471
+ ### 7. MD5 verification
472
+
473
+ `calculateMD5Hash` is optional in `FlashOptions`, and esptool-js ships no MD5 of its own. `hash-wasm`
474
+ (what the server uses) is async and cannot satisfy the sync `(Uint8Array) => string` signature. Omit
475
+ it initially and confirm `writeFlash` skips verification cleanly rather than throwing; if
476
+ verification matters, add a small sync MD5 helper.
477
+
478
+ ### 8. Async style
479
+
480
+ esptool-js is promise-native, so this is an acknowledged exception to CLAUDE.md's callback
481
+ preference: keep one `async` function and bridge to a callback at the boundary so calling code stays
482
+ callback-style.
483
+
484
+ ### 9. i18n
485
+
486
+ Every new string added to all four language sections (EN/FR/HI/ID). Use `textContent` — literal
487
+ strings passed as `el()` *children* bypass translation entirely.
488
+
489
+ ---
490
+
491
+ ## Server changes (frugal-iot-server)
492
+
493
+ ### 1. Required: a route to download an OTA binary
494
+
495
+ `/ota_list/:org` returns directory names only. Phase 4 above needs the bytes. Add, mirroring the auth
496
+ of the existing `/ota_delete` route (`frugal-iot-server.js:900`):
497
+
498
+ ```javascript
499
+ app.get('/ota_get/:org/*remainingpath',
500
+ loggedInOrFail,
501
+ can_OTAUPDATE,
502
+ (req, res) => {
503
+ const remainingpath = req.params.remainingpath.join('/');
504
+ res.sendFile(`${config.server.otadir}/${req.params.org}/${sanitize(remainingpath)}/firmware.bin`);
505
+ }
506
+ );
507
+ ```
508
+
509
+ Do **not** reuse the existing `GET /ota_update/:org/:project/:node/:attribs`
510
+ (`frugal-iot-server.js:708`) for this. It is intentionally unauthenticated (nodes call it) and applies
511
+ MD5/304 logic that would return `304` instead of bytes.
512
+
513
+ ### 2. No change needed: serving `base/`
514
+
515
+ The client directory is `config.server.htmldir`, served by `express.static` under **`/dashboard`** —
516
+ not by the `publicdir` catch-all, which is a different directory. So the base files are reachable at
517
+ `/dashboard/base/...`, and the client must therefore use a **relative** URL (`./base`), exactly as
518
+ `CssUrl = './frugaliot.css'` already does. An absolute `/base/...` would 404.
519
+
520
+ Two caveats:
521
+
522
+ - That handler sets `immutable, maxAge: 1 day`. If a base binary is ever corrected in place, browsers
523
+ hold the stale copy for a day. Cheap fix — carry the `version` from `boards.json` as a query string
524
+ when fetching the parts.
525
+ - A relative URL means the flasher only works on a page served from the client directory. That is
526
+ fine for the OTA tab, but the flasher could not be dropped into an externally hosted
527
+ `index-embedded.html` without switching to an absolute base URL.
528
+
529
+ ### 3. Verify only: no restrictive `Permissions-Policy`
530
+
531
+ Web Serial in a top-level page needs no special header, but a `Permissions-Policy` response header
532
+ that omits `serial` would block it. Confirm the server sends none; if the flasher is ever placed in
533
+ an iframe, the *parent* page needs `allow="serial"` on the iframe.
534
+
535
+ ### 4. Explicitly not needed: COOP/COEP
536
+
537
+ `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy` are only required for
538
+ `SharedArrayBuffer`, which none of this uses. Adding them would break other things for no benefit.
539
+
540
+ ---
541
+
542
+ ## Phasing
543
+
544
+ | Phase | Delivers | Server change | Status |
545
+ |---|---|---|---|
546
+ | 1 | `MqttFlash` element; local `.bin`; chip + flash-size detection; app-only flash at the detected app-slot offset; progress + esptool log | none | built |
547
+ | 2 | Base bundles committed for C3/S2/S3; partition-table parsing; full provision in one `writeFlash`; all four refusal checks | none | built |
548
+ | 3 | Read-back detection of provision vs. app-only, with the override button; serial boot log streamed after reset | none | built |
549
+ | 4 | ⚡ on the OTA file list — flash a server-hosted binary | `/ota_get` route | built |
550
+ | 5 | *(optional)* serial config hand-off, needs a firmware addition | none | not started |
551
+
552
+ ### What is verified, and what still needs a board
553
+
554
+ Verified without hardware: partition tables round-trip to the intended geometry (app slot identical in
555
+ all three schemes, each table summing exactly to its flash size); parsing of real `partitions.bin`
556
+ files, of erased flash, and of garbage; littlefs superblock detection; `imageChipId`; `boards.json`
557
+ internally consistent and every referenced file present; base files served correctly at
558
+ `/dashboard/base/...` and byte-identical over HTTP; `/ota_get` returns 401 unauthenticated.
559
+
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:
564
+
565
+ - whether `writeFlash` tolerates the omitted `calculateMD5Hash` (see Client change 7)
566
+ - 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
+ - whether unsupported octal flash on an S3 really does surface as the capacity-byte fallback
569
+
570
+ ---
571
+
572
+ ## Open questions
573
+
574
+ - **Octal-flash S3 detection.** Confirm on a real ESP32-S3-WROOM-2 that unsupported OPI flash shows up
575
+ as `detectFlashSize()`'s unrecognized-capacity fallback, so the refusal message can be stated as
576
+ fact rather than a guess.
577
+ - Confirm `writeFlash` tolerates an omitted `calculateMD5Hash` (see Client change 7).
578
+ - **Ship 4 MB only at first.** Larger boards are still more expensive and not seen in practice, so
579
+ `min_spiffs` is the only scheme that needs to work on day one. The `frugal_8mb` / `frugal_16mb`
580
+ tables above are specified so they *can* be added without redesign, but they should be generated and
581
+ tested by someone holding the hardware rather than committed untested — an unlisted
582
+ `<chip>-<flashsize>` already fails cleanly as "unsupported board".
583
+ - Whether an ESP32-S2 `bootloader.bin` is worth harvesting up front, given `ARDUINO_LOLIN_S2_MINI`
584
+ appears in the firmware but may not be in active use.
@@ -0,0 +1,25 @@
1
+ {
2
+ "version": 1,
3
+ "coreVersion": "esp32-3.3.11",
4
+ "comment": "Base bundles for USB flashing - see FLASH_PLAN.md. Bootloaders are dio_80m and are flash-size independent (esptool patches the header at write time). Partition tables are chip-independent. boot_app0.bin is universal.",
5
+ "otadata": "boot_app0.bin",
6
+ "partitionTableOffset": 32768,
7
+ "bootloaderByChip": {
8
+ "ESP32-C3": "bootloader/esp32c3.bin",
9
+ "ESP32-S2": "bootloader/esp32s2.bin",
10
+ "ESP32-S3": "bootloader/esp32s3.bin"
11
+ },
12
+ "schemeByChipAndSize": {
13
+ "ESP32-C3-4MB": "min_spiffs",
14
+ "ESP32-S2-4MB": "min_spiffs",
15
+ "ESP32-S3-4MB": "min_spiffs",
16
+ "ESP32-S3-8MB": "frugal_8mb",
17
+ "ESP32-S3-16MB": "frugal_16mb"
18
+ },
19
+ "partitionsByScheme": {
20
+ "min_spiffs": "partitions/min_spiffs.bin",
21
+ "frugal_8mb": "partitions/frugal_8mb.bin",
22
+ "frugal_16mb": "partitions/frugal_16mb.bin"
23
+ },
24
+ "wholeImageChips": ["ESP8266"]
25
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,8 @@
1
+ # Frugal IoT 16MB: app partitions identical to min_spiffs, all surplus to LittleFS (label must stay "spiffs")
2
+ # Name, Type, SubType, Offset, Size, Flags
3
+ nvs, data, nvs, 0x9000, 0x5000,
4
+ otadata, data, ota, 0xe000, 0x2000,
5
+ app0, app, ota_0, 0x10000, 0x1E0000,
6
+ app1, app, ota_1, 0x1F0000,0x1E0000,
7
+ spiffs, data, spiffs, 0x3D0000,0xC20000,
8
+ coredump, data, coredump,0xFF0000,0x10000,
Binary file
@@ -0,0 +1,8 @@
1
+ # Frugal IoT 8MB: app partitions identical to min_spiffs, all surplus to LittleFS (label must stay "spiffs")
2
+ # Name, Type, SubType, Offset, Size, Flags
3
+ nvs, data, nvs, 0x9000, 0x5000,
4
+ otadata, data, ota, 0xe000, 0x2000,
5
+ app0, app, ota_0, 0x10000, 0x1E0000,
6
+ app1, app, ota_1, 0x1F0000,0x1E0000,
7
+ spiffs, data, spiffs, 0x3D0000,0x420000,
8
+ coredump, data, coredump,0x7F0000,0x10000,
Binary file
@@ -0,0 +1,7 @@
1
+ # Name, Type, SubType, Offset, Size, Flags
2
+ nvs, data, nvs, 0x9000, 0x5000,
3
+ otadata, data, ota, 0xe000, 0x2000,
4
+ app0, app, ota_0, 0x10000, 0x1E0000,
5
+ app1, app, ota_1, 0x1F0000,0x1E0000,
6
+ spiffs, data, spiffs, 0x3D0000,0x20000,
7
+ coredump, data, coredump,0x3F0000,0x10000,