frugal-iot-client 2.0.5 → 2.0.7

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/core.js CHANGED
@@ -66,6 +66,11 @@ function nextUniqueId() { return ++unique_id; }
66
66
  const RECEIVER_ATTRIBUTES = ['value','color','type','label','topic','graphable','wired','wiring'];
67
67
  let server_config; // { user, organizations, logger, mqtt, server } - note loaded by MqttWrapper OR MqttAdmin
68
68
 
69
+ // An embedded page loads no /config.json, so assume the logger is on the server these modules came from
70
+ function loggerUrl() {
71
+ return server_config?.logger?.url ?? new URL('/data', import.meta.url).href;
72
+ }
73
+
69
74
  // This structure defines each of the common Input/Output types included within a sensor or acctuator
70
75
  // NOTE BELOW COPIED TO SERVER config.d/schema on 20 Feb - append any changes since then
71
76
 
@@ -208,6 +213,13 @@ function mqtt_deliver(topic, msg, retained) {
208
213
  }
209
214
  }
210
215
  }
216
+ // Drop one subscription, made with mqtt_subscribe, that was only wanted for a while.
217
+ function mqtt_unsubscribe(topic) {
218
+ mqtt_subscriptions = mqtt_subscriptions.filter((s) => s.topic !== topic);
219
+ if (mqtt_client && mqtt_client.connected) {
220
+ mqtt_client.unsubscribe(topic, (err) => { if (err) console.error(err); });
221
+ }
222
+ }
211
223
  // Drop every subscription belonging to an organization - all its topics start with the organization id.
212
224
  // Called when switching organization, both to tell the broker (if still connected) and so that the
213
225
  // connection made for the new organization does not resubscribe to the old organization's topics.
@@ -423,7 +435,7 @@ EN:
423
435
  close: close
424
436
  Collapse: Collapse
425
437
  Color: Color
426
- "Compiled into a node's firmware, this is what lets it ask this server for its own broker credential. It grants nothing else - no reading and no publishing. Add a second one to change it: both are accepted, so nodes already flashed keep working until you withdraw the old one.": "Compiled into a node's firmware, this is what lets it ask this server for its own broker credential. It grants nothing else - no reading and no publishing. Add a second one to change it: both are accepted, so nodes already flashed keep working until you withdraw the old one."
438
+ "Compiled into firmware and used to request broker credential. Add a second one to change it: both are accepted, so nodes already flashed keep working until you withdraw the old one.": "Compiled into firmware and used to request broker credential. Add a second one to change it: both are accepted, so nodes already flashed keep working until you withdraw the old one."
427
439
  Connect board: Connect board
428
440
  connected: connected
429
441
  connecting: connecting
@@ -446,7 +458,6 @@ EN:
446
458
  Done: Done
447
459
  ds18b20: ds18b20
448
460
  e.g. LiteFarm: e.g. LiteFarm
449
- Each node has its own broker credential, which it collects from this server the first time it connects. Forget one to have it issued a new credential - needed if its filesystem has been erased, because it can then no longer prove which node it is.: Each node has its own broker credential, which it collects from this server the first time it connects. Forget one to have it issued a new credential - needed if its filesystem has been erased, because it can then no longer prove which node it is.
450
461
  eCO2: eCO2
451
462
  Email: Email
452
463
  Enrol URL: Enrol URL
@@ -533,7 +544,7 @@ EN:
533
544
  No farms registered for this organization yet.: No farms registered for this organization yet.
534
545
  No nodes found for this farm's project(s).: No nodes found for this farm's project(s).
535
546
  No nodes found for this organization: No nodes found for this organization
536
- No nodes have enrolled yet. A node enrols itself the first time it connects.: No nodes have enrolled yet. A node enrols itself the first time it connects.
547
+ No nodes have enrolled yet, and none have asked and been rejected.: No nodes have enrolled yet, and none have asked and been rejected.
537
548
  No organization: No organization
538
549
  No organization selected: No organization selected
539
550
  No OTA files uploaded yet.: No OTA files uploaded yet.
@@ -550,6 +561,7 @@ EN:
550
561
  Nodes: Nodes
551
562
  Nodes in Farm: Nodes in Farm
552
563
  Nodes in Organization: Nodes in Organization
564
+ Nodes that have obtained broker credentials on first connection. Forget reissues a new credential at next connection and is needed if memory has been erased. Refused nodes are listed - click Approve to allow (on next retry, typically 30 minutes).: Nodes that have obtained broker credentials on first connection. Forget reissues a new credential at next connection and is needed if memory has been erased. Refused nodes are listed - click Approve to allow (on next retry, typically 30 minutes).
553
565
  Not in the schema: Not in the schema
554
566
  Not selected: Not selected
555
567
  Note this is your organization - not the organizations whose devices you want to access.: Note this is your organization - not the organizations whose devices you want to access.
@@ -581,6 +593,8 @@ EN:
581
593
  Pressure: Pressure
582
594
  Project: Project
583
595
  Project changed to: Project changed to
596
+ Asked %s to move: Asked %s to move
597
+ Waiting for %s to report: Waiting for %s to report
584
598
  Project ID: Project ID
585
599
  Project ID *: Project ID *
586
600
  Project Name: Project Name
@@ -733,7 +747,7 @@ FR:
733
747
  close: fermer
734
748
  Collapse: Réduire
735
749
  Color: Couleur
736
- "Compiled into a node's firmware, this is what lets it ask this server for its own broker credential. It grants nothing else - no reading and no publishing. Add a second one to change it: both are accepted, so nodes already flashed keep working until you withdraw the old one.": "Compilé dans le micrologiciel d'un nœud, c'est ce qui lui permet de demander à ce serveur son propre identifiant de broker. Il n'accorde rien d'autre - ni lecture ni publication. Ajoutez-en un second pour le changer : les deux sont acceptés, donc les nœuds déjà flashés continuent de fonctionner jusqu'à ce que vous retiriez l'ancien."
750
+ "Compiled into firmware and used to request broker credential. Add a second one to change it: both are accepted, so nodes already flashed keep working until you withdraw the old one.": "Compilé dans le micrologiciel et utilisé pour demander l'identifiant de broker. Ajoutez-en un second pour le changer : les deux sont acceptés, donc les nœuds déjà flashés continuent de fonctionner jusqu'à ce que vous retiriez l'ancien."
737
751
  Connect board: Connecter la carte
738
752
  connected: connecté
739
753
  connecting: connexion
@@ -756,7 +770,6 @@ FR:
756
770
  Done: Fait
757
771
  ds18b20: ds18b20
758
772
  e.g. LiteFarm: par ex. LiteFarm
759
- Each node has its own broker credential, which it collects from this server the first time it connects. Forget one to have it issued a new credential - needed if its filesystem has been erased, because it can then no longer prove which node it is.: Chaque nœud a son propre identifiant de broker, qu'il récupère sur ce serveur lors de sa première connexion. Oubliez-en un pour qu'un nouvel identifiant lui soit délivré - nécessaire si son système de fichiers a été effacé, car il ne peut alors plus prouver quel nœud il est.
760
773
  eCO2: eCO2
761
774
  Email: Email
762
775
  Enrol URL: URL d'enrôlement
@@ -843,7 +856,7 @@ FR:
843
856
  No farms registered for this organization yet.: Aucune ferme enregistrée pour cette organisation pour l'instant.
844
857
  No nodes found for this farm's project(s).: Aucun nœud trouvé pour le(s) projet(s) de cette ferme.
845
858
  No nodes found for this organization: Aucun nœud trouvé pour cette organisation
846
- No nodes have enrolled yet. A node enrols itself the first time it connects.: Aucun nœud ne s'est encore enrôlé. Un nœud s'enrôle lui-même lors de sa première connexion.
859
+ No nodes have enrolled yet, and none have asked and been rejected.: Aucun nœud ne s'est encore enrôlé, et aucun n'a été refusé.
847
860
  No organization: Aucune organisation
848
861
  No organization selected: Aucune organisation sélectionnée
849
862
  No OTA files uploaded yet.: Aucun fichier OTA téléversé pour l'instant.
@@ -860,6 +873,7 @@ FR:
860
873
  Nodes: Nœuds
861
874
  Nodes in Farm: Nœuds dans la ferme
862
875
  Nodes in Organization: Nœuds dans l'organisation
876
+ Nodes that have obtained broker credentials on first connection. Forget reissues a new credential at next connection and is needed if memory has been erased. Refused nodes are listed - click Approve to allow (on next retry, typically 30 minutes).: Nœuds ayant obtenu leur identifiant de broker à la première connexion. Oublier en délivre un nouveau à la prochaine connexion, nécessaire si la mémoire a été effacée. Les nœuds refusés sont listés - cliquez sur Approuver pour autoriser (à la prochaine tentative, généralement 30 minutes).
863
877
  Not in the schema: Absent du schéma
864
878
  Not selected: Non sélectionné
865
879
  Note this is your organization - not the organizations whose devices you want to access.: Ceci est votre organisation - pas les organisations dont vous voulez accéder aux appareils.
@@ -891,6 +905,8 @@ FR:
891
905
  Pressure: Pression
892
906
  Project: Projet
893
907
  Project changed to: Projet changé en
908
+ Asked %s to move: Déplacement demandé à %s
909
+ Waiting for %s to report: En attente du rapport de %s
894
910
  Project ID: ID du projet
895
911
  Project ID *: ID du projet *
896
912
  Project Name: Nom du projet
@@ -1043,7 +1059,7 @@ HI:
1043
1059
  close: बंद करें
1044
1060
  Collapse: छोटा करें
1045
1061
  Color: रंग
1046
- "Compiled into a node's firmware, this is what lets it ask this server for its own broker credential. It grants nothing else - no reading and no publishing. Add a second one to change it: both are accepted, so nodes already flashed keep working until you withdraw the old one.": "नोड के फ़र्मवेयर में संकलित, यही उसे इस सर्वर से अपना ब्रोकर क्रेडेंशियल माँगने देता है। यह और कुछ नहीं देता - न पढ़ना, न प्रकाशित करना। इसे बदलने के लिए दूसरा जोड़ें: दोनों स्वीकार किए जाते हैं, इसलिए पहले फ़्लैश किए गए नोड तब तक काम करते रहते हैं जब तक आप पुराना वापस नहीं लेते।"
1062
+ "Compiled into firmware and used to request broker credential. Add a second one to change it: both are accepted, so nodes already flashed keep working until you withdraw the old one.": "फ़र्मवेयर में संकलित और ब्रोकर क्रेडेंशियल माँगने के लिए उपयोग किया जाता है। इसे बदलने के लिए दूसरा जोड़ें: दोनों स्वीकार किए जाते हैं, इसलिए पहले फ़्लैश किए गए नोड तब तक काम करते रहते हैं जब तक आप पुराना वापस नहीं लेते।"
1047
1063
  Connect board: बोर्ड कनेक्ट करें
1048
1064
  connected: जुड़े हुए
1049
1065
  connecting: कनेक्ट हो रहा है
@@ -1066,7 +1082,6 @@ HI:
1066
1082
  Done: पूर्ण
1067
1083
  ds18b20: ds18b20
1068
1084
  e.g. LiteFarm: उदाहरण के लिए LiteFarm
1069
- Each node has its own broker credential, which it collects from this server the first time it connects. Forget one to have it issued a new credential - needed if its filesystem has been erased, because it can then no longer prove which node it is.: हर नोड का अपना ब्रोकर क्रेडेंशियल होता है, जो वह पहली बार जुड़ते समय इस सर्वर से लेता है। किसी को भुला दें ताकि उसे नया क्रेडेंशियल मिले - यह ज़रूरी है यदि उसका फ़ाइल-सिस्टम मिटा दिया गया हो, क्योंकि तब वह यह सिद्ध नहीं कर सकता कि वह कौन सा नोड है।
1070
1085
  eCO2: ईसीओ2
1071
1086
  Email: ईमेल
1072
1087
  Enrol URL: पंजीकरण URL
@@ -1153,7 +1168,7 @@ HI:
1153
1168
  No farms registered for this organization yet.: इस संगठन के लिए अभी तक कोई फार्म पंजीकृत नहीं है।
1154
1169
  No nodes found for this farm's project(s).: इस फार्म के प्रोजेक्ट (प्रोजेक्ट्स) के लिए कोई नोड नहीं मिला।
1155
1170
  No nodes found for this organization: इस संगठन के लिए कोई नोड नहीं मिला
1156
- No nodes have enrolled yet. A node enrols itself the first time it connects.: अभी तक कोई नोड पंजीकृत नहीं हुआ है। नोड पहली बार जुड़ते समय स्वयं पंजीकृत हो जाता है।
1171
+ No nodes have enrolled yet, and none have asked and been rejected.: अभी तक कोई नोड पंजीकृत नहीं हुआ है, और किसी को अस्वीकार नहीं किया गया है।
1157
1172
  No organization: कोई संगठन नहीं
1158
1173
  No organization selected: कोई संगठन चयनित नहीं
1159
1174
  No OTA files uploaded yet.: अभी तक कोई OTA फ़ाइल अपलोड नहीं की गई है।
@@ -1170,6 +1185,7 @@ HI:
1170
1185
  Nodes: नोड्स
1171
1186
  Nodes in Farm: फार्म में नोड्स
1172
1187
  Nodes in Organization: संगठन में नोड्स
1188
+ Nodes that have obtained broker credentials on first connection. Forget reissues a new credential at next connection and is needed if memory has been erased. Refused nodes are listed - click Approve to allow (on next retry, typically 30 minutes).: वे नोड जिन्होंने पहली बार जुड़ने पर ब्रोकर क्रेडेंशियल प्राप्त किए हैं। भुलाएँ अगली बार जुड़ने पर नया क्रेडेंशियल जारी करता है, और यह ज़रूरी है यदि मेमोरी मिटा दी गई हो। अस्वीकृत नोड भी सूचीबद्ध हैं - अनुमति देने के लिए स्वीकृत करें पर क्लिक करें (अगले प्रयास पर, आमतौर पर 30 मिनट)।
1173
1189
  Not in the schema: स्कीमा में नहीं
1174
1190
  Not selected: चयनित नहीं
1175
1191
  Note this is your organization - not the organizations whose devices you want to access.: ध्यान दें यह आपका संगठन है - वे संगठन नहीं जिनके उपकरणों तक आप पहुंचना चाहते हैं।
@@ -1201,6 +1217,8 @@ HI:
1201
1217
  Pressure: दाब
1202
1218
  Project: परियोजना
1203
1219
  Project changed to: प्रोजेक्ट बदलकर किया गया
1220
+ Asked %s to move: "%s को स्थानांतरित करने को कहा"
1221
+ Waiting for %s to report: "%s की रिपोर्ट की प्रतीक्षा"
1204
1222
  Project ID: प्रोजेक्ट आईडी
1205
1223
  Project ID *: प्रोजेक्ट आईडी *
1206
1224
  Project Name: प्रोजेक्ट का नाम
@@ -1353,7 +1371,7 @@ ID:
1353
1371
  close: tutup
1354
1372
  Collapse: Ciutkan
1355
1373
  Color: Warna
1356
- "Compiled into a node's firmware, this is what lets it ask this server for its own broker credential. It grants nothing else - no reading and no publishing. Add a second one to change it: both are accepted, so nodes already flashed keep working until you withdraw the old one.": "Dikompilasi ke dalam firmware sebuah node, inilah yang memungkinkannya meminta kredensial broker miliknya sendiri dari server ini. Ia tidak memberi apa pun selain itu - tidak membaca dan tidak menerbitkan. Tambahkan yang kedua untuk menggantinya: keduanya diterima, sehingga node yang sudah di-flash tetap bekerja sampai Anda mencabut yang lama."
1374
+ "Compiled into firmware and used to request broker credential. Add a second one to change it: both are accepted, so nodes already flashed keep working until you withdraw the old one.": "Dikompilasi ke dalam firmware dan digunakan untuk meminta kredensial broker. Tambahkan yang kedua untuk menggantinya: keduanya diterima, sehingga node yang sudah di-flash tetap bekerja sampai Anda mencabut yang lama."
1357
1375
  Connect board: Hubungkan papan
1358
1376
  connected: terhubung
1359
1377
  connecting: menghubungkan
@@ -1376,7 +1394,6 @@ ID:
1376
1394
  Done: Selesai
1377
1395
  ds18b20: ds18b20
1378
1396
  e.g. LiteFarm: misalnya LiteFarm
1379
- Each node has its own broker credential, which it collects from this server the first time it connects. Forget one to have it issued a new credential - needed if its filesystem has been erased, because it can then no longer prove which node it is.: Setiap node memiliki kredensial broker sendiri, yang diambilnya dari server ini saat pertama kali tersambung. Lupakan salah satu agar diberi kredensial baru - diperlukan jika sistem berkasnya telah dihapus, karena node itu tidak lagi dapat membuktikan node yang mana dirinya.
1380
1397
  eCO2: eCO2
1381
1398
  Email: Email
1382
1399
  Enrol URL: URL pendaftaran
@@ -1463,7 +1480,7 @@ ID:
1463
1480
  No farms registered for this organization yet.: Belum ada farm yang terdaftar untuk organisasi ini.
1464
1481
  No nodes found for this farm's project(s).: Tidak ada node ditemukan untuk proyek farm ini.
1465
1482
  No nodes found for this organization: Tidak ada node ditemukan untuk organisasi ini
1466
- No nodes have enrolled yet. A node enrols itself the first time it connects.: Belum ada node yang mendaftar. Sebuah node mendaftarkan dirinya sendiri saat pertama kali tersambung.
1483
+ No nodes have enrolled yet, and none have asked and been rejected.: Belum ada node yang terdaftar, dan tidak ada yang ditolak.
1467
1484
  No organization: Tidak ada organisasi
1468
1485
  No organization selected: Tidak ada organisasi yang dipilih
1469
1486
  No OTA files uploaded yet.: Belum ada berkas OTA yang diunggah.
@@ -1480,6 +1497,7 @@ ID:
1480
1497
  Nodes: Node
1481
1498
  Nodes in Farm: Node dalam Farm
1482
1499
  Nodes in Organization: Node dalam Organisasi
1500
+ Nodes that have obtained broker credentials on first connection. Forget reissues a new credential at next connection and is needed if memory has been erased. Refused nodes are listed - click Approve to allow (on next retry, typically 30 minutes).: Node yang telah memperoleh kredensial broker saat pertama kali tersambung. Lupakan menerbitkan kredensial baru pada sambungan berikutnya, dan diperlukan jika memori telah dihapus. Node yang ditolak juga dicantumkan - klik Setujui untuk mengizinkan (pada percobaan berikutnya, biasanya 30 menit).
1483
1501
  Not in the schema: Tidak ada di skema
1484
1502
  Not selected: Tidak dipilih
1485
1503
  Note this is your organization - not the organizations whose devices you want to access.: Ini adalah organisasi Anda - bukan organisasi yang perangkatnya ingin Anda akses.
@@ -1511,6 +1529,8 @@ ID:
1511
1529
  Pressure: Tekanan
1512
1530
  Project: Proyek
1513
1531
  Project changed to: Proyek diubah menjadi
1532
+ Asked %s to move: Meminta %s pindah
1533
+ Waiting for %s to report: Menunggu laporan %s
1514
1534
  Project ID: ID Proyek
1515
1535
  Project ID *: ID Proyek *
1516
1536
  Project Name: Nama Proyek
@@ -2171,7 +2191,9 @@ class MqttTopic {
2171
2191
  } else {
2172
2192
  // Headless path: update the data tree directly (also reached by detached MqttGraphDataset topics).
2173
2193
  // topicSetPath throws if nodeMt is null, so guard the startsWith call with nodeMt.
2174
- const isParam = this.nodeMt && topic.startsWith(this.topicSetPath + '/');
2194
+ // The node publishes its own wired paths on the read path, the UX writes them on the set path.
2195
+ const isParam = this.nodeMt
2196
+ && (topic.startsWith(this.topicPath + '/') || topic.startsWith(this.topicSetPath + '/'));
2175
2197
  if (isParam) {
2176
2198
  // Parameter subtopic arriving (e.g. now/wired, temperature/max).
2177
2199
  if (topic.split('/').pop() === 'wired') this.setWired(message);
@@ -2314,7 +2336,7 @@ class MqttTopic {
2314
2336
  addDataFrom(filename, first, cb) {
2315
2337
  //TODO this location may change
2316
2338
  // noinspection JSUnresolvedReference
2317
- let filepath = `${server_config.logger.url}/${this.topicPath}/${filename}`;
2339
+ let filepath = `${loggerUrl()}/${this.topicPath}/${filename}`;
2318
2340
  console.log("Adding from", filepath);
2319
2341
  //let self = this; // if needed in Promise
2320
2342
  fetch(filepath)
@@ -3332,6 +3354,7 @@ export {
3332
3354
  mqttTempConnect,
3333
3355
  mqtt_deliver,
3334
3356
  mqtt_subscribe,
3357
+ mqtt_unsubscribe,
3335
3358
  mqtt_unsubscribe_organization,
3336
3359
  nextUniqueId,
3337
3360
  nowMs,
@@ -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
@@ -9,7 +9,7 @@
9
9
  "dependencies": {
10
10
  "async": "^3.2.6",
11
11
  "chart.js": "^4.5.1",
12
- "csv-parse": "^5.6.0",
12
+ "csv-parse": "^7.0.3",
13
13
  "dial-gauge": "^0.1.5",
14
14
  "esptool-js": "^0.6.1",
15
15
  "html-element-extended": "^0.1.7",
@@ -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.7"
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);
@@ -260,6 +260,15 @@ describe('controls and wiring', () => {
260
260
  assert.equal(nowMt.wiredTopic, nodeMt.groups.sht.topics.temperature);
261
261
  });
262
262
 
263
+ test('a wired path the node publishes on its read path is a parameter, not a value', () => {
264
+ const { projectMt } = mock.runScenario('control-wired');
265
+ const groupMt = projectMt.nodes['esp8266-fb94bb'].groups.controlhysteresis;
266
+ mock.deliver('dev/lotus/esp8266-fb94bb/controlhysteresis/out/wired', 'dev/lotus/esp8266-fb94bb/set/relay/on');
267
+ assert.equal(groupMt.state.out_wired, 'dev/lotus/esp8266-fb94bb/set/relay/on');
268
+ assert.equal(groupMt.topics.out.wired, 'dev/lotus/esp8266-fb94bb/set/relay/on');
269
+ assert.equal(typeof groupMt.summaryShort(), 'string');
270
+ });
271
+
263
272
  test('an unwired control has no wiredTopic', () => {
264
273
  const { projectMt } = mock.runScenario('control-unwired');
265
274
  const nowMt = projectMt.nodes['esp8266-fb94bb'].groups.controlhysteresis?.topics?.now;
@@ -47,4 +47,19 @@ describe('embedded widgets', () => {
47
47
  // Not delivering a message here: dataChanged() calls chart.update(), and Chart.js cannot
48
48
  // measure text without a canvas, which jsdom does not have.
49
49
  });
50
+
51
+ test('a graphdataset fetches history from the logger with no server config', (t, done) => {
52
+ document.body.innerHTML =
53
+ '<mqtt-graph><mqtt-graphdataset topic="dev/lotus/esp32-784702/sht/temperature" color="red" label="Office Temperature" min=0 max=50 type="float"></mqtt-graphdataset></mqtt-graph>';
54
+ const ds = document.querySelector('mqtt-graphdataset');
55
+ const realFetch = globalThis.fetch;
56
+ let asked;
57
+ globalThis.fetch = (url) => { asked = url; return Promise.resolve({ ok: false, status: 404, statusText: 'Not Found' }); };
58
+ ds.mt.addDataFrom('2026-10-18.csv', true, (err) => {
59
+ globalThis.fetch = realFetch;
60
+ assert.equal(err, null);
61
+ assert.match(asked, /\/data\/dev\/lotus\/esp32-784702\/sht\/temperature\/2026-10-18\.csv$/);
62
+ done();
63
+ });
64
+ });
50
65
  });