frugal-iot-client 2.0.5 → 2.0.6

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.
@@ -55,7 +55,7 @@ So an ESP32 board needs three extra regions that are **independent of your sketc
55
55
  | Piece | Depends on | Per-app? |
56
56
  |---|---|---|
57
57
  | `bootloader.bin` | chip + flash mode/freq | No |
58
- | `partitions.bin` | partition scheme (`min_spiffs`, `frugal_8mb`, …) | No |
58
+ | `partitions.bin` | partition scheme (`min_spiffs`, `default_8MB`, …) | No |
59
59
  | `boot_app0.bin` | nothing — fixed 8192-byte blob | No |
60
60
  | `firmware.bin` | your app | Yes — this is what the OTA tab already stores |
61
61
 
@@ -194,18 +194,25 @@ This can be decided with high confidence by reading the flash back before writin
194
194
  exposes `readFlash()`, and with the stub loaded, reading ~7 KB is fast.
195
195
 
196
196
  1. Read 3 KB at `0x8000`. Absent partition-table magic (`AA 50`) → no valid layout → **provision**.
197
- 2. Parse the on-device table and check the **app slot only** — `app0` at `0x10000`, size `0x1E0000`.
198
- Differs → the board is laid out for something else → **provision**.
197
+ 2. Parse the on-device table and check the **app slot's position only** — `app0` at `0x10000`.
198
+ Elsewhere → the board is laid out for something else → **provision**. Its size is not compared
199
+ here; it becomes the limit the image is checked against once one is chosen (see the refusals).
199
200
  3. Read 4 bytes at `chip.BOOTLOADER_FLASH_OFFSET`. Not `0xE9` → **provision**.
200
201
  4. Read the first 4 KB of the on-device FS partition (offset from *its* table, not ours) and look for
201
202
  the littlefs superblock magic — the ASCII string `littlefs`. Present → a filesystem exists worth
202
203
  preserving → **app-only**.
203
204
 
204
- Comparing only the app slot rather than the whole table is deliberate, and it's what the fixed app
205
- geometry buys: a board whose FS partition is a different size than our scheme would produce is still
206
- perfectly safe to update app-only. That correctly covers a board provisioned by an earlier version of
207
- this tool, or flashed by hand from PlatformIO with stock `min_spiffs`, instead of needlessly wiping
208
- its config.
205
+ Comparing only where the app slot starts, rather than the whole table, is deliberate. An app image
206
+ runs from any slot it fits in, so a board laid out differently from our scheme — a bigger app slot,
207
+ a filesystem elsewhere — is still perfectly safe to update app-only. That correctly covers a board
208
+ provisioned by an earlier version of this tool, or flashed by hand from PlatformIO with any stock
209
+ scheme (`min_spiffs`, `default`, `default_8MB`), instead of needlessly wiping its config.
210
+
211
+ **App-only also resets otadata.** A board that has taken a wireless OTA update may be booting from
212
+ `app1`, and would then ignore the app just written to `app0`. So the app-only path writes
213
+ `boot_app0.bin` at the otadata offset from the *board's own* table — exactly what a PlatformIO
214
+ upload does — which selects `app0` without touching the filesystem. If the table could not be read,
215
+ otadata is left alone and the log says the old firmware may keep booting.
209
216
 
210
217
  That resolves the "is it virgin?" question definitively. The one residue is step 4's blind spot: a
211
218
  matching partition table plus a valid LittleFS **belonging to a different project**. Flash contents
@@ -272,42 +279,40 @@ base/
272
279
  partitions/
273
280
  min_spiffs.bin 3 KB — chip-independent (Arduino stock, 4 MB)
274
281
  min_spiffs.csv — provenance for the above
275
- frugal_8mb.bin — custom, see below
276
- frugal_8mb.csv
277
- frugal_16mb.bin
278
- frugal_16mb.csv
282
+ default_8MB.bin — Arduino stock, see below
283
+ default_8MB.csv
284
+ default_16MB.bin
285
+ default_16MB.csv
279
286
  ```
280
287
 
281
288
  Target chip set: **ESP8266** (no base needed at all), **ESP32-C3**, **ESP32-S2**, **ESP32-S3**.
282
289
  `min_spiffs` for the smaller chips, per Frugal IoT convention.
283
290
 
284
- ### Partition schemes: fixed app size, all spare space to LittleFS
285
-
286
- Bigger boards get the *same* app partitions as a 4 MB board, with the surplus going to the
287
- filesystem. Arduino's stock large-flash schemes do the opposite (`default_8MB` gives 3.1 MB app
288
- slots and only 1.5 MB of FS), so these are custom tables.
289
-
290
- Starting from stock `min_spiffs` (4 MB), which is unchanged:
291
+ ### Partition schemes: the stock tables PlatformIO uses
291
292
 
292
- | Partition | Type/Subtype | Offset | `min_spiffs` (4 MB) | `frugal_8mb` | `frugal_16mb` |
293
- |---|---|---|---|---|---|
294
- | `nvs` | data/nvs | `0x9000` | `0x5000` | `0x5000` | `0x5000` |
295
- | `otadata` | data/ota | `0xe000` | `0x2000` | `0x2000` | `0x2000` |
296
- | `app0` | app/ota_0 | `0x10000` | `0x1E0000` | `0x1E0000` | `0x1E0000` |
297
- | `app1` | app/ota_1 | `0x1F0000` | `0x1E0000` | `0x1E0000` | `0x1E0000` |
298
- | `spiffs` | data/spiffs | `0x3D0000` | `0x20000` (128 KB) | `0x420000` (4.125 MB) | `0xC20000` (12.125 MB) |
299
- | `coredump` | data/coredump | *varies* | `0x3F0000` | `0x7F0000` | `0xFF0000` |
293
+ Every scheme is Arduino's own, unmodified, so a board provisioned here has exactly the table a
294
+ PlatformIO upload of the matching env writes. That is the point: a USB upload from PlatformIO onto a
295
+ board provisioned here — or the reverse — rewrites the table with an identical one, and the
296
+ filesystem (with the board's WiFi and MQTT config) stays where it is.
300
297
 
301
- App partitions stay 64 KB-aligned and the FS partition stays 4 KB-aligned in all three, and each
302
- table sums exactly to its flash size.
298
+ | Partition | Type/Subtype | `min_spiffs` (4 MB) | `default_8MB` | `default_16MB` |
299
+ |---|---|---|---|---|
300
+ | `nvs` | data/nvs | `0x9000` `0x5000` | `0x9000` `0x5000` | `0x9000` `0x5000` |
301
+ | `otadata` | data/ota | `0xe000` `0x2000` | `0xe000` `0x2000` | `0xe000` `0x2000` |
302
+ | `app0` | app/ota_0 | `0x10000` `0x1E0000` | `0x10000` `0x330000` | `0x10000` `0x640000` |
303
+ | `app1` | app/ota_1 | `0x1F0000` `0x1E0000` | `0x340000` `0x330000` | `0x650000` `0x640000` |
304
+ | `spiffs` | data/spiffs | `0x3D0000` `0x20000` | `0x670000` `0x180000` | `0xC90000` `0x360000` |
305
+ | `coredump` | data/coredump | `0x3F0000` `0x10000` | `0x7F0000` `0x10000` | `0xFF0000` `0x10000` |
303
306
 
304
- **Two consequences worth relying on:**
307
+ **What is relied on:** `app0` is at `0x10000` in every scheme, so the app-only path always writes
308
+ there. Its size varies, and an image fits any slot it is no bigger than — so the same
309
+ `firmware.bin` from a 4 MB build flashes to an 8 or 16 MB board, and a wireless OTA moves it between
310
+ `app0` and `app1` regardless. The image is checked against the slot actually on the board.
305
311
 
306
- 1. **The app image is flash-size-independent.** `app0` has the same offset *and* size in every
307
- scheme, so one `firmware.bin` flashes to a 4, 8 or 16 MB board. The OTA files in the tab need no
308
- per-size variants, and the app-only path always writes at `0x10000`.
309
- 2. **A 1.875 MB app slot is generous** and unchanged from what 4 MB boards already run, so nothing
310
- about existing builds needs revisiting.
312
+ An earlier version of this plan used custom `frugal_8mb`/`frugal_16mb` tables (`min_spiffs` app
313
+ slots, all surplus to LittleFS). They were dropped because the firmware's PlatformIO envs build for
314
+ the board default, and a PlatformIO upload onto a board provisioned with a different table moves the
315
+ filesystem and loses its config.
311
316
 
312
317
  **Keep the label `spiffs`, even though the content is LittleFS.** Arduino's LittleFS mounts by
313
318
  partition label, defaulting to `"spiffs"`
@@ -315,10 +320,9 @@ partition label, defaulting to `"spiffs"`
315
320
  Renaming the partition to `littlefs` would stop the firmware mounting it unless `begin()` is also
316
321
  changed. Not worth the coupling.
317
322
 
318
- These CSVs are Frugal IoT's own, so they belong in the **firmware** repo (so builds can reference them
319
- via `board_build.partitions`), with the generated `.bin` plus a copy of the `.csv` committed here for
320
- the flasher. Generate with the core's `tools/gen_esp32part.py`, or just harvest
321
- `.pio/build/<env>/partitions.bin` from a build configured with that CSV.
323
+ The `.csv` files are copied from the core's `tools/partitions/`, and the `.bin` generated from them
324
+ with the core's `tools/gen_esp32part.py` — or harvested from `.pio/build/<env>/partitions.bin` of a
325
+ build using that scheme.
322
326
 
323
327
  `boards.json` maps a detected `<chip>-<flashsize>` to the parts to use, so lookup after detection is
324
328
  a single key:
@@ -329,8 +333,8 @@ a single key:
329
333
  "ESP32-C3-4MB": "min_spiffs",
330
334
  "ESP32-S2-4MB": "min_spiffs",
331
335
  "ESP32-S3-4MB": "min_spiffs",
332
- "ESP32-S3-8MB": "frugal_8mb",
333
- "ESP32-S3-16MB": "frugal_16mb"
336
+ "ESP32-S3-8MB": "default_8MB",
337
+ "ESP32-S3-16MB": "default_16MB"
334
338
  },
335
339
  "bootloaderByChip": {
336
340
  "ESP32-C3": "bootloader/esp32c3.bin",
@@ -380,8 +384,8 @@ support without touching the client code:
380
384
 
381
385
  1. Build the firmware for the board.
382
386
  2. If the **chip** is new, copy `bootloader.bin` to `base/bootloader/<chip>.bin`.
383
- 3. If the **flash size** is new, write a CSV following the table above — same app partitions, all
384
- surplus to `spiffs` — and commit both it and the generated `.bin` to `base/partitions/`.
387
+ 3. If the **flash size** is new, copy the core's stock CSV for it — the one the board's PlatformIO
388
+ env uses — and commit both it and the generated `.bin` to `base/partitions/`.
385
389
  4. Add one `"<CHIP>-<SIZE>": "<scheme>"` line to `schemeByChipAndSize`.
386
390
 
387
391
  `boot_app0.bin` never needs touching, and the app binary never needs rebuilding for a different flash
@@ -397,8 +401,10 @@ All four are **refusals, not warnings** — a bad combination here means a board
397
401
  2. App image: byte 0 == `0xE9`, and for ESP32-family images `chip_id` (u16 LE at offset 12) ==
398
402
  `esploader.chip.IMAGE_CHIP_ID` (0=ESP32, 2=S2, 5=C3, 9=S3, 12=C2, 13=C6, 16=H2 — the ROM classes
399
403
  expose it). This catches the classic "C3 build onto a D1 Mini".
400
- 3. App byte length ≤ app-slot size from the parsed partition table. Catches a `min_spiffs` app
401
- dropped onto a `default`-scheme board.
404
+ 3. App byte length ≤ app-slot size — the board's own slot for an app-only write, the base scheme's
405
+ when provisioning. Catches a `default_8MB` build too big for a `min_spiffs` board. A too-big image
406
+ is refused rather than provisioned automatically, since provisioning erases the board's config;
407
+ the message says when "Provision instead" would make it fit.
402
408
  4. `detectFlashSize()` must produce a `<chip>-<flashsize>` key present in `schemeByChipAndSize` — and
403
409
  if the size came from the `"4MB"` fallback, require confirmation (and on an S3, warn about octal
404
410
  flash).
@@ -847,9 +853,8 @@ Nothing structural — the flow works but is a little unintuitive. Known rough e
847
853
  fact rather than a guess.
848
854
  - Confirm `writeFlash` tolerates an omitted `calculateMD5Hash` (see Client change 7).
849
855
  - **Ship 4 MB only at first.** Larger boards are still more expensive and not seen in practice, so
850
- `min_spiffs` is the only scheme that needs to work on day one. The `frugal_8mb` / `frugal_16mb`
851
- tables above are specified so they *can* be added without redesign, but they should be generated and
852
- tested by someone holding the hardware rather than committed untested — an unlisted
856
+ `min_spiffs` is the only scheme that needs to work on day one. `default_8MB` / `default_16MB` are
857
+ the stock tables, but provisioning with them is untested on real hardware — an unlisted
853
858
  `<chip>-<flashsize>` already fails cleanly as "unsupported board".
854
859
  - Whether an ESP32-S2 `bootloader.bin` is worth harvesting up front, given `ARDUINO_LOLIN_S2_MINI`
855
860
  appears in the firmware but may not be in active use.
package/flash.js CHANGED
@@ -13,9 +13,11 @@ const PartitionTableOffset = 0x8000;
13
13
  const PartitionTableMagic = 0x50aa; // AA 50 on the wire
14
14
  const PartitionType = {app: 0x00, data: 0x01};
15
15
  const PartitionSubtype = {factory: 0x00, ota0: 0x10, otadata: 0x00, spiffs: 0x82, littlefs: 0x83};
16
- // Every Frugal IoT scheme keeps app0 here, so one firmware.bin suits any flash size - see FLASH_PLAN.md
16
+ // Every Arduino scheme keeps app0 here; only its size varies, and an image fits any slot it is no
17
+ // bigger than - see FLASH_PLAN.md
17
18
  const AppPartitionOffset = 0x10000;
18
- const AppPartitionSize = 0x1e0000;
19
+ // The smallest app0 we provision (min_spiffs) - the limit when the board's own table is unreadable
20
+ const AppPartitionSizeMin = 0x1e0000;
19
21
  const ImageMagic = 0xe9;
20
22
  const LittleFsMagic = [0x6c, 0x69, 0x74, 0x74, 0x6c, 0x65, 0x66, 0x73]; // "littlefs" in the superblock
21
23
  const FlashBaudRates = [921600, 460800, 230400, 115200];
@@ -238,7 +240,6 @@ class MqttFlash extends HTMLElementExtended {
238
240
  dev.wholeImage = true; // ESP8266 firmware.bin is a complete image at 0x0
239
241
  dev.provision = false; // and does not overlap the filesystem
240
242
  dev.appAddress = 0x0;
241
- dev.appLimit = null;
242
243
  return;
243
244
  }
244
245
  const scheme = boards.schemeByChipAndSize[`${dev.chipName}-${dev.flashSize}`];
@@ -255,11 +256,14 @@ class MqttFlash extends HTMLElementExtended {
255
256
  partitions: {url: partUrl(boards.partitionsByScheme[scheme]), address: boards.partitionTableOffset},
256
257
  otadata: {url: partUrl(boards.otadata), address: null}, // from the partition table below
257
258
  };
259
+ // Fetched now rather than at flash time, so the size check knows the base's app slot
260
+ const baseApp = partitionApp(partitionsParse(await fetchBytes(dev.base.partitions.url)));
261
+ dev.base.appSize = baseApp ? baseApp.size : AppPartitionSizeMin;
258
262
  // The filesystem partition is deliberately left erased - the firmware's LittleFS.begin(true)
259
263
  // formats it on first boot. See FLASH_PLAN.md: it only appeared unable to when we were writing a
260
264
  // bootloader from the wrong framework.
261
265
  dev.appAddress = AppPartitionOffset;
262
- dev.appLimit = AppPartitionSize;
266
+ dev.onDeviceAppSize = AppPartitionSizeMin;
263
267
  await this.deviceInspect();
264
268
  }
265
269
  // Returns null rather than stalling, so a board that will not answer the stub's read command does
@@ -287,6 +291,7 @@ class MqttFlash extends HTMLElementExtended {
287
291
  dev.inspected = false;
288
292
  dev.provision = false;
289
293
  this.logLine("Could not inspect the board - assuming it is already provisioned. Use \"Provision instead\" if this is a new board.");
294
+ this.logLine("Without its partition table otadata cannot be reset, so a board that has taken an OTA update may keep booting the old firmware.");
290
295
  return;
291
296
  }
292
297
  dev.inspected = true;
@@ -295,8 +300,13 @@ class MqttFlash extends HTMLElementExtended {
295
300
  const app = partitionApp(onDeviceParts);
296
301
  if (!onDeviceParts.length) reasons.push("no partition table");
297
302
  else if (!app) reasons.push("no app partition");
298
- // Only the app slot has to match - a differently sized filesystem is still safe to keep
299
- else if (app.offset !== AppPartitionOffset || app.size !== AppPartitionSize) reasons.push("app partition differs");
303
+ // Only the app slot's position has to match - its size is checked against the image at flash
304
+ // time, and a differently laid out filesystem is still safe to keep
305
+ else if (app.offset !== AppPartitionOffset) reasons.push("app partition not at 0x10000");
306
+ if (app) dev.onDeviceAppSize = app.size;
307
+ // Only an ota_0 slot is selected through otadata - a factory app boots without it
308
+ const otadata = partitionOtadata(onDeviceParts);
309
+ if (app && app.subtype === PartitionSubtype.ota0 && otadata) dev.onDeviceOtadataOffset = otadata.offset;
300
310
  // Worth always logging - a layout that disagrees with the base explains most boot failures
301
311
  if (onDeviceParts.length) {
302
312
  this.logLine("On-device partition table:");
@@ -359,8 +369,15 @@ class MqttFlash extends HTMLElementExtended {
359
369
  if (chipId !== this.state.esploader.chip.IMAGE_CHIP_ID) {
360
370
  throw new Error(`${this.state.app.name} was built for chip id ${chipId}, but this board is ${dev.chipName} (chip id ${this.state.esploader.chip.IMAGE_CHIP_ID})`);
361
371
  }
362
- if (dev.appLimit && bytes.length > dev.appLimit) {
363
- throw new Error(`${this.state.app.name} is ${bytesToSizeString(bytes.length)}, larger than the ${bytesToSizeString(dev.appLimit)} app partition`);
372
+ // Not provisioned automatically: that erases the board's configuration, so it is the user's call
373
+ if (dev.provision && bytes.length > dev.base.appSize) {
374
+ throw new Error(`${this.state.app.name} is ${bytesToSizeString(bytes.length)}, larger than the ${bytesToSizeString(dev.base.appSize)} app partition of ${dev.scheme}`);
375
+ }
376
+ if (!dev.provision && bytes.length > dev.onDeviceAppSize) {
377
+ const fix = bytes.length <= dev.base.appSize
378
+ ? ` - "Provision instead" would lay it out as ${dev.scheme}, but erases its configuration`
379
+ : "";
380
+ throw new Error(`${this.state.app.name} is ${bytesToSizeString(bytes.length)}, larger than the ${bytesToSizeString(dev.onDeviceAppSize)} app partition on this board${fix}`);
364
381
  }
365
382
  }
366
383
  if (dev.flashSizeGuessed && !this.state.confirmed) {
@@ -381,6 +398,9 @@ class MqttFlash extends HTMLElementExtended {
381
398
  files.push({data: partitions, address: dev.base.partitions.address});
382
399
  files.push({data: otadata, address: otadataPart.offset});
383
400
  dev.verifyAddress = dev.base.partitions.address; // read back below to confirm the write landed
401
+ } else if (!dev.wholeImage && dev.onDeviceOtadataOffset != null) {
402
+ // After an OTA update the board boots app1, so the app written to app0 would never run
403
+ files.push({data: await fetchBytes(dev.base.otadata.url), address: dev.onDeviceOtadataOffset});
384
404
  }
385
405
  files.push({data: this.state.app.bytes, address: dev.appAddress});
386
406
  return files;
package/frugaliot.css CHANGED
@@ -193,6 +193,8 @@ div.mqtt-text {
193
193
  }
194
194
 
195
195
 
196
+ @keyframes fi-spin { to { transform: rotate(360deg); } }
197
+
196
198
  /* Related to authentication */
197
199
  .mqtt-admin {
198
200
  .pseudolink {
@@ -201,7 +203,9 @@ div.mqtt-text {
201
203
  cursor: pointer;
202
204
  }
203
205
  /* The \27f3 beside a heading: still a link, but underlining a glyph reads as a typo. */
204
- .refresh { text-decoration: none; }
206
+ .refresh { text-decoration: none; display: inline-block; margin-left: 0.3em; }
207
+ /* Spins until the fetch it started replies - on a slow link a still glyph got clicked again and again. */
208
+ .refresh.busy { animation: fi-spin 1s linear infinite; cursor: progress; }
205
209
  /* An OTA row's glyphs: the padding is the whole hit area, so a mis-tap cannot reach delete. */
206
210
  .otaicon { text-decoration: none; padding: 0 0.3em; }
207
211
  .message {margin: 0.5em; font-weight: bold; font-size: x-large; }
package/package.json CHANGED
@@ -41,5 +41,5 @@
41
41
  "unlink:hee": "rm -f node_modules/html-element-extended && npm install html-element-extended"
42
42
  },
43
43
  "type": "module",
44
- "version": "2.0.5"
44
+ "version": "2.0.6"
45
45
  }
@@ -237,6 +237,16 @@ describe('back mode', () => {
237
237
  card.remove();
238
238
  });
239
239
 
240
+ test('a control that is not a hysteresis still shows its topics to wire', () => {
241
+ const { card } = cardFor('display-control', 'esp32-48ed20', { mode: 'back', at: T0 });
242
+ const titles = [...card.querySelectorAll('.fi-section__title')].map((h) => h.textContent);
243
+ assert.ok(titles.includes('LCD SHT'), `no LCD section in ${titles}`);
244
+ for (const leaf of ['temperature', 'humidity', 'message']) {
245
+ assert.ok(card.querySelector(`[topic$="control_lcd_ht/${leaf}"]`), `no widget for ${leaf}`);
246
+ }
247
+ card.remove();
248
+ });
249
+
240
250
  test('nothing but Advanced is collapsed', () => {
241
251
  const { card } = cardFor('control-wired', 'esp8266-fb94bb', { mode: 'back', at: T0 });
242
252
  assert.equal(card.querySelectorAll('details').length, 1);
@@ -206,6 +206,141 @@
206
206
  }
207
207
  ]
208
208
  },
209
+ "control_lcd_ht": {
210
+ "summary": false,
211
+ "name": "LCD SHT",
212
+ "topics": [
213
+ {
214
+ "leaf": "temperature",
215
+ "leaf_from": "controlfloat",
216
+ "name": "Temperature",
217
+ "min": -40,
218
+ "max": 85,
219
+ "color": "#ff0000"
220
+ },
221
+ {
222
+ "leaf": "humidity",
223
+ "leaf_from": "controlfloat",
224
+ "name": "Humidity",
225
+ "color": "#0000ff"
226
+ },
227
+ {
228
+ "leaf": "message",
229
+ "leaf_from": "controltext",
230
+ "name": "Message",
231
+ "rw": "r"
232
+ }
233
+ ]
234
+ },
235
+ "control_oled_gps": {
236
+ "summary": false,
237
+ "name": "OLED GPS",
238
+ "topics": [
239
+ {
240
+ "leaf": "latitude",
241
+ "leaf_from": "controlfloat",
242
+ "name": "Latitude",
243
+ "min": -90,
244
+ "max": 90,
245
+ "width": 10,
246
+ "color": "#0000ff"
247
+ },
248
+ {
249
+ "leaf": "longitude",
250
+ "leaf_from": "controlfloat",
251
+ "name": "Longitude",
252
+ "min": -180,
253
+ "max": 180,
254
+ "width": 11,
255
+ "color": "#0000ff"
256
+ },
257
+ {
258
+ "leaf": "altitude",
259
+ "leaf_from": "controlfloat",
260
+ "name": "Altitude",
261
+ "min": -500,
262
+ "max": 9000,
263
+ "width": 6,
264
+ "color": "#008000"
265
+ },
266
+ {
267
+ "leaf": "speed",
268
+ "leaf_from": "controlfloat",
269
+ "name": "Speed",
270
+ "max": 999,
271
+ "color": "#ffa500"
272
+ },
273
+ {
274
+ "leaf": "course",
275
+ "leaf_from": "controlfloat",
276
+ "name": "Course",
277
+ "max": 360
278
+ },
279
+ {
280
+ "leaf": "satellites",
281
+ "leaf_from": "controlfloat",
282
+ "name": "Satellites",
283
+ "max": 32
284
+ },
285
+ {
286
+ "leaf": "hdop",
287
+ "leaf_from": "controlfloat",
288
+ "name": "HDOP",
289
+ "max": 50
290
+ }
291
+ ]
292
+ },
293
+ "control_oled_ht": {
294
+ "summary": false,
295
+ "name": "OLED HT",
296
+ "topics": [
297
+ {
298
+ "leaf": "temperature",
299
+ "leaf_from": "controlfloat",
300
+ "name": "Temperature",
301
+ "max": 50,
302
+ "color": "#ff0000"
303
+ },
304
+ {
305
+ "leaf": "humidity",
306
+ "leaf_from": "controlfloat",
307
+ "name": "Humidity",
308
+ "color": "#0000ff"
309
+ },
310
+ {
311
+ "leaf": "battery",
312
+ "leaf_from": "controlfloat",
313
+ "name": "Battery",
314
+ "max": 5000,
315
+ "color": "#00ff00"
316
+ }
317
+ ]
318
+ },
319
+ "control_oled_loramesher": {
320
+ "summary": false,
321
+ "name": "OLED LoRaMesher",
322
+ "topics": [
323
+ {
324
+ "leaf": "battery",
325
+ "leaf_from": "controlfloat",
326
+ "name": "Battery",
327
+ "max": 5000,
328
+ "color": "#00ff00"
329
+ }
330
+ ]
331
+ },
332
+ "control_oled_single": {
333
+ "summary": false,
334
+ "name": "OLED",
335
+ "topics": [
336
+ {
337
+ "leaf": "value",
338
+ "leaf_from": "controlfloat",
339
+ "name": "Value",
340
+ "max": 10000
341
+ }
342
+ ]
343
+ },
209
344
  "controlblinken": {
210
345
  "name": "Control Blinken",
211
346
  "topics": [
@@ -415,6 +550,24 @@
415
550
  }
416
551
  ]
417
552
  },
553
+ "hcsr04": {
554
+ "name": "HC-SR04 Distance",
555
+ "topics": [
556
+ {
557
+ "leaf": "hcsr04",
558
+ "leaf_from": "ultrasonic",
559
+ "max": 4000
560
+ },
561
+ {
562
+ "leaf": "temperature",
563
+ "leaf_from": "controlfloat",
564
+ "name": "Air Temperature",
565
+ "min": -20,
566
+ "max": 50,
567
+ "color": "#ff0000"
568
+ }
569
+ ]
570
+ },
418
571
  "health": {
419
572
  "name": "System",
420
573
  "insidefrugaliot": true,
@@ -838,14 +991,16 @@
838
991
  "leaf_from": "controlfloat",
839
992
  "name": "Battery",
840
993
  "min": 0,
841
- "max": 16000
994
+ "max": 16000,
995
+ "log": true
842
996
  },
843
997
  {
844
998
  "leaf": "panel",
845
999
  "leaf_from": "controlfloat",
846
1000
  "name": "Panel",
847
1001
  "min": 0,
848
- "max": 30000
1002
+ "max": 30000,
1003
+ "log": true
849
1004
  },
850
1005
  {
851
1006
  "leaf": "profile",
@@ -889,6 +1044,18 @@
889
1044
  }
890
1045
  ]
891
1046
  },
1047
+ "time": {
1048
+ "name": "Time",
1049
+ "insidefrugaliot": true,
1050
+ "topics": [
1051
+ {
1052
+ "leaf": "epoch"
1053
+ },
1054
+ {
1055
+ "leaf": "offset"
1056
+ }
1057
+ ]
1058
+ },
892
1059
  "ultrasonic": {
893
1060
  "name": "Ultrasonic Distance",
894
1061
  "topics": [
@@ -1178,6 +1345,16 @@
1178
1345
  "rw": "r",
1179
1346
  "graphable": true
1180
1347
  },
1348
+ "epoch": {
1349
+ "leaf": "epoch",
1350
+ "name": "Epoch",
1351
+ "type": "int",
1352
+ "log": false,
1353
+ "display": "text",
1354
+ "color": "#000000",
1355
+ "rw": "r",
1356
+ "units": "s"
1357
+ },
1181
1358
  "gas": {
1182
1359
  "leaf": "gas",
1183
1360
  "name": "Gas Resistance",
@@ -1412,6 +1589,16 @@
1412
1589
  "rw": "w",
1413
1590
  "retain": true
1414
1591
  },
1592
+ "offset": {
1593
+ "leaf": "offset",
1594
+ "name": "UTC Offset",
1595
+ "type": "int",
1596
+ "log": false,
1597
+ "display": "text",
1598
+ "color": "#000000",
1599
+ "rw": "r",
1600
+ "units": "min"
1601
+ },
1415
1602
  "on": {
1416
1603
  "leaf": "on",
1417
1604
  "name": "On",
@@ -1,8 +1,9 @@
1
1
  // The flasher's hold on the serial port. WebSerial refuses a second open of the same port, so the
2
2
  // element is the only thing that can close it - and a connect that fails is exactly when it must.
3
3
  import './setup.js';
4
- import { test, describe, before, beforeEach, afterEach } from 'node:test';
4
+ import { test, describe, before, after, beforeEach, afterEach } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
+ import { readFileSync } from 'node:fs';
6
7
 
7
8
  let ports; // what a fake Transport did, in order
8
9
 
@@ -218,3 +219,87 @@ describe('the serial monitor', () => {
218
219
  flash.remove();
219
220
  });
220
221
  });
222
+
223
+ /*
224
+ * Provision vs app-only, against real partition tables from base/. A board is only re-laid out when
225
+ * its app slot is somewhere else - a different size is checked against the image instead, since
226
+ * provisioning erases the board's configuration.
227
+ */
228
+ describe('flashing over a board that is already laid out', () => {
229
+ const readBase = (p) => new Uint8Array(readFileSync(new URL(`../base/${p}`, import.meta.url)));
230
+ const boards = JSON.parse(readFileSync(new URL('../base/boards.json', import.meta.url), 'utf8'));
231
+ let writes;
232
+ // An 8MB S3 whose flash holds `table` at 0x8000, a bootloader, and an erased filesystem
233
+ function fakeS3(table) {
234
+ return async () => ({
235
+ Transport: class { async disconnect() {} },
236
+ ESPLoader: class {
237
+ constructor() { this.chip = {CHIP_NAME: 'ESP32-S3', BOOTLOADER_FLASH_OFFSET: 0, IMAGE_CHIP_ID: 9}; }
238
+ async main() { return 'ESP32-S3'; }
239
+ async readFlashId() { return 0x17 << 16; }
240
+ async readFlash(offset, length) {
241
+ const block = new Uint8Array(length).fill(0xff);
242
+ if (offset === 0) block[0] = 0xe9;
243
+ if (offset === 0x8000) block.set(table.subarray(0, length));
244
+ return block;
245
+ }
246
+ async writeFlash({fileArray}) { writes.push(...fileArray.map((f) => f.address)); }
247
+ async after() {}
248
+ },
249
+ });
250
+ }
251
+ async function connected(table) {
252
+ const flash = document.createElement('mqtt-flash');
253
+ document.body.append(flash);
254
+ flash.esptoolImport = fakeS3(table);
255
+ flash.state.boards = boards;
256
+ flash.monitorStart = () => {};
257
+ await flash.connectAndInspect({});
258
+ return flash;
259
+ }
260
+ const image = (size) => { const b = new Uint8Array(size); b[0] = 0xe9; b[12] = 9; return b; };
261
+ let realFetch;
262
+ before(() => {
263
+ realFetch = globalThis.fetch;
264
+ globalThis.fetch = async (url) => {
265
+ const bytes = readBase(String(url).replace(/^\.\/base\//, '').replace(/\?.*$/, ''));
266
+ return {ok: true, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)};
267
+ };
268
+ });
269
+ after(() => { globalThis.fetch = realFetch; });
270
+ beforeEach(() => { writes = []; });
271
+
272
+ test('a board on a bigger app slot than our scheme is updated app-only, keeping its config', async () => {
273
+ const flash = await connected(readBase('partitions/default_8MB.bin'));
274
+ assert.equal(flash.state.device.provision, false, flash.state.log.join('\n'));
275
+ flash.state.app = {name: 'firmware.bin', bytes: image(0x200000)}; // too big for min_spiffs, fits here
276
+ await flash.flash();
277
+ assert.deepEqual(writes, [0xe000, 0x10000], 'otadata reset, then the app - nothing else');
278
+ flash.remove();
279
+ });
280
+
281
+ test('an app-only write resets otadata where the board says, so it boots app0 and not an older app1', async () => {
282
+ const flash = await connected(readBase('partitions/min_spiffs.bin'));
283
+ flash.state.app = {name: 'firmware.bin', bytes: image(0x1000)};
284
+ await flash.flash();
285
+ assert.ok(writes.includes(0xe000), 'otadata should have been written');
286
+ flash.remove();
287
+ });
288
+
289
+ test('an image too big for the board is refused, not provisioned behind the user’s back', async () => {
290
+ const flash = await connected(readBase('partitions/min_spiffs.bin'));
291
+ flash.state.app = {name: 'firmware.bin', bytes: image(0x200000)};
292
+ await assert.rejects(flash.flash(), /larger than the 1\.88MB app partition on this board - "Provision instead"/);
293
+ assert.deepEqual(writes, []);
294
+ flash.remove();
295
+ });
296
+
297
+ test('a board whose app slot is somewhere else is provisioned', async () => {
298
+ const table = readBase('partitions/min_spiffs.bin').slice();
299
+ new DataView(table.buffer).setUint32(2 * 32 + 4, 0x20000, true); // app0 is the third record
300
+ const flash = await connected(table);
301
+ assert.equal(flash.state.device.provision, true);
302
+ assert.deepEqual(flash.state.device.provisionReasons, ['app partition not at 0x10000']);
303
+ flash.remove();
304
+ });
305
+ });