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.
- package/.claude/settings.local.json +4 -1
- package/FLASH_PLAN.md +584 -0
- package/base/boards.json +25 -0
- package/base/boot_app0.bin +0 -0
- package/base/bootloader/esp32c3.bin +0 -0
- package/base/bootloader/esp32s2.bin +0 -0
- package/base/bootloader/esp32s3.bin +0 -0
- package/base/partitions/frugal_16mb.bin +0 -0
- package/base/partitions/frugal_16mb.csv +8 -0
- package/base/partitions/frugal_8mb.bin +0 -0
- package/base/partitions/frugal_8mb.csv +8 -0
- package/base/partitions/min_spiffs.bin +0 -0
- package/base/partitions/min_spiffs.csv +7 -0
- package/dashboard_example.html +2 -1
- package/frugaliot.css +21 -0
- package/index-embedded.html +2 -1
- package/index-project.html +2 -1
- package/index.html +2 -1
- package/login.html +2 -1
- package/package.json +2 -1
- package/webcomponents.js +560 -18
|
@@ -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.
|
package/base/boards.json
ADDED
|
@@ -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,
|