frugal-iot-client 2.0.3 → 2.0.5
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.md +6 -1
- package/admin.js +77 -29
- package/cards.js +19 -0
- package/core.js +149 -6
- package/docs/FLASH_PLAN.md +30 -0
- package/flash.js +128 -37
- package/frugaliot.css +2 -0
- package/graph.js +1 -1
- package/package.json +1 -1
- package/test/flash.test.js +220 -0
- package/test/page.test.js +118 -0
- package/test/setup.js +4 -3
package/CLAUDE.md
CHANGED
|
@@ -280,6 +280,7 @@ const mt = document.querySelector('mqtt-wrapper')?.projectMt?.findTopic(this.sta
|
|
|
280
280
|
| `frugaliot:organizationchanged` | `MqttWrapper.onOrganization` | `{ organization }` | capabilities are per organization, so this precedes any project |
|
|
281
281
|
| `frugaliot:cardmove` / `cardmoveover` | `mqtt-devicecard` | `{ nodeId, delta }` / `{ nodeId, targetNodeId }` | the card asks to be moved; the grid decides what that means |
|
|
282
282
|
| `frugaliot:cardmode` | `mqtt-devicecard` | `{ nodeId, mode }` | so the grid can remember what was open |
|
|
283
|
+
| `frugaliot:flashfile` | `mqtt-admin` (OTA section) | `{ org, path }` | the ⚡ beside an OTA file: `mqtt-projectback` opens the Flash card and passes it on |
|
|
283
284
|
|
|
284
285
|
---
|
|
285
286
|
|
|
@@ -358,11 +359,14 @@ Every `addVocabulary` block must include **all four** language sections (EN, FR,
|
|
|
358
359
|
|
|
359
360
|
### How a string gets translated
|
|
360
361
|
|
|
361
|
-
There are
|
|
362
|
+
There are four ways a piece of UI text is translated. If a string doesn't go through one of these, it silently stays in English regardless of the selected language:
|
|
362
363
|
|
|
363
364
|
1. **Direct call to `getString(tag)`** — looks the string up in `languages`, falling back to the English value, falling back to `tag` itself. Used for names built dynamically (e.g. a graph's scale/axis name at `text: getString(this.name...)`).
|
|
364
365
|
2. **Via `el(tag, attributes, children)`** — `el()` auto-translates certain *attributes* on certain *tags*, per the `i8ntags` table (currently `label`, `button`, `span`, `option`, `p`, `h1`–`h5`, `th`, all via their `textContent` attribute). Add a tag/attribute pair to `i8ntags` when a new kind of element needs translated text. Excluded even on a listed tag: values containing `:` or `/` (these are usually paths or key:value pairs, not prose), and values not starting with a letter (emoji/symbol-only content). Pass `i8n: false` on an element you know is untranslatable (a proper name, an id, dynamic per-row data).
|
|
365
366
|
3. **Via a graph's scale name** — same as (1), a direct `getString()` call when building the Chart.js scale config.
|
|
367
|
+
4. **Via `getStringWith(tag, value)`** — prose with a name in it. The table entry holds a `%s` and the value is substituted after the lookup: `getStringWith("Forget %s?", nodeId)`. Concatenating a translated fragment with the name instead pins the word order to English, which is wrong in HI. The `window.confirm` dialogs in `admin.js` are built this way.
|
|
368
|
+
|
|
369
|
+
**Prose that `el()` skips can still be translated.** A paragraph containing a URL or a colon is excluded by (2), so call `getString()` on it yourself and pass `i8n: false` — otherwise `el()` looks up the already-translated text, misses, and falls back to translating it word by word.
|
|
366
370
|
|
|
367
371
|
**Only `textContent` is translated — never a literal string passed as a DOM `children` argument.** `el('p', {}, ["Some text"])` bypasses the mechanism entirely because the filter inspects `attributes`, not `children`. Always write `el('p', {textContent: "Some text"})` instead. This applies to `p`, `h1`–`h5`, `span`, and any other tag in `i8ntags`.
|
|
368
372
|
|
|
@@ -374,6 +378,7 @@ When adding a new UI string: add matching entries to all four language sections
|
|
|
374
378
|
|
|
375
379
|
- `el(tag, attributes, children)` — local wrapper around `EL` that applies i18n translation to label/button/span text. Pass `i8n: false` in attributes to suppress translation for names/IDs.
|
|
376
380
|
- `getString(tag)` — look up a string in the current language(s); falls back to English.
|
|
381
|
+
- `getStringWith(tag, value)` — as `getString`, then substitutes `value` for `%s` in the result, so a translation chooses where the name falls in the sentence.
|
|
377
382
|
- `mqtt_subscribe(topic, cb)` — registers a subscription; replays on reconnect.
|
|
378
383
|
- `GET(url, opts, cb)` — from `html-element-extended`; callback-style HTTP GET returning parsed JSON.
|
|
379
384
|
- `XXX(args)` — debug log with a breakpoint hook; use for unexpected states. Leave calls in place.
|
package/admin.js
CHANGED
|
@@ -7,9 +7,23 @@
|
|
|
7
7
|
|
|
8
8
|
import {EL, GET, HTMLElementExtended} from '/node_modules/html-element-extended/htmlelementextended.js';
|
|
9
9
|
import mqtt from '/node_modules/mqtt/dist/mqtt.esm.js'; // https://www.npmjs.com/package/mqtt
|
|
10
|
-
import { CssUrl, DELETE, POST, XXX, brokerHost, configSet, el, getString, hasCapability, mqttTempConnect, mqtt_subscribe, retainedPattern, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
|
|
10
|
+
import { CssUrl, DELETE, POST, XXX, brokerHost, configSet, el, getString, getStringWith, hasCapability, mqttTempConnect, mqtt_subscribe, retainedPattern, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
// The OTA upload is multipart, which POST() (JSON) cannot carry. Callback-style, like the rest.
|
|
14
|
+
// The server answers every outcome with a redirect carrying ?message=, so the final URL is the reply.
|
|
15
|
+
function postFormData(url, formData, cb) {
|
|
16
|
+
fetch(url, {method: 'POST', body: formData})
|
|
17
|
+
.then((res) => {
|
|
18
|
+
if (res.body) res.body.cancel(); // the redirect lands on a page we have no use for
|
|
19
|
+
const message = new URL(res.url, window.location.href).searchParams.get('message');
|
|
20
|
+
// No message means this was not the redirect we expect - a 401 back to login, say - so do not
|
|
21
|
+
// report a success the server did not claim
|
|
22
|
+
cb(null, message || (res.ok ? "Upload finished - check the list below" : `Upload failed (${res.status})`));
|
|
23
|
+
})
|
|
24
|
+
.catch((err) => cb(err));
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
// ---------- USB flashing over WebSerial ----------
|
|
14
28
|
// The .bin files the OTA tab handles are application images. An ESP32 also needs a bootloader,
|
|
15
29
|
// partition table and otadata, which are app-independent and kept in base/ - see FLASH_PLAN.md.
|
|
@@ -181,16 +195,47 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
181
195
|
console.log(ev,val);
|
|
182
196
|
this.getOrDeleteOtaFiles(`/ota_delete/${val}`);
|
|
183
197
|
}
|
|
198
|
+
/*
|
|
199
|
+
* The flasher is a card of its own (cards.js), which may not be open - or even built - when the
|
|
200
|
+
* ⚡ beside a file here is clicked. So this asks, and mqtt-projectback opens that card and
|
|
201
|
+
* passes the request on to its mqtt-admin.
|
|
202
|
+
*/
|
|
184
203
|
onOtaFlash(path) {
|
|
185
|
-
|
|
186
|
-
|
|
204
|
+
document.dispatchEvent(new CustomEvent('frugaliot:flashfile',
|
|
205
|
+
{detail: {org: this.state.org, path}}));
|
|
206
|
+
}
|
|
207
|
+
// Called by mqtt-projectback on the flash card, once it is open: the mqtt-flash is in this shadow root.
|
|
208
|
+
flashRemote(org, path) {
|
|
209
|
+
const flashEl = this.renderRoot.querySelector('mqtt-flash');
|
|
210
|
+
if (!flashEl) { XXX(["flashRemote on an admin element that is not the flash section", this.state.section]); return; }
|
|
211
|
+
flashEl.setRemoteSource(org, path);
|
|
212
|
+
}
|
|
213
|
+
/*
|
|
214
|
+
* Posted here rather than by the browser: the server answers with a redirect to the `url` field
|
|
215
|
+
* below, so a plain form submit left the whole page on /dashboard/ with no organization chosen -
|
|
216
|
+
* away from the card that asked, and with the admin cards shut again.
|
|
217
|
+
*/
|
|
218
|
+
onOtaUpload(ev) {
|
|
219
|
+
ev.preventDefault();
|
|
220
|
+
const form = ev.target;
|
|
221
|
+
this.message("Uploading");
|
|
222
|
+
postFormData('/ota_update', new FormData(form), (err, message) => {
|
|
223
|
+
this.message(err ? err.message : message, !err);
|
|
224
|
+
if (!err) form.reset();
|
|
225
|
+
this.getOtaFiles(); // says more than the message does: the file is either listed or it is not
|
|
226
|
+
});
|
|
187
227
|
}
|
|
188
228
|
otaFilesList() {
|
|
189
229
|
return this.state.ota_files.length === 0 ?
|
|
190
230
|
el('p', {textContent: "No OTA files uploaded yet."}) :
|
|
191
231
|
el('p', {}, this.state.ota_files.map(f => [
|
|
192
|
-
|
|
193
|
-
|
|
232
|
+
hasCapability(this.state.org, 'OTAFLASH') // no flash card to send it to otherwise
|
|
233
|
+
? el('span', {class: 'pseudolink otaicon', title: getString("Flash this over USB"), textContent: '⚡', onclick: this.onOtaFlash.bind(this, f)})
|
|
234
|
+
: null,
|
|
235
|
+
// The basket alone deletes; the name beside it downloads, which is what a name invites.
|
|
236
|
+
el('span', {class: 'pseudolink otaicon', title: getString("Delete this firmware"), textContent: '🗑', onclick: this.onOtaDelete.bind(this,`${this.state.org}/${f}`)}),
|
|
237
|
+
el('a', {href: `/ota_get/${this.state.org}/${f}`, download: `${f.replace(/\//g, '-')}-firmware.bin`,
|
|
238
|
+
title: getString("Download this firmware"), textContent: f, i8n: false}),
|
|
194
239
|
el('br', {}),
|
|
195
240
|
])
|
|
196
241
|
);
|
|
@@ -233,9 +278,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
233
278
|
// Irreversible for any node still holding it, and those nodes cannot be told - they are the
|
|
234
279
|
// ones not yet enrolled, so nothing on the server knows they exist.
|
|
235
280
|
if (!window.confirm(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
281
|
+
getString("Withdraw this enrolment secret?") + "\n\n" +
|
|
282
|
+
getString("Any node flashed with it that has not enrolled yet will be refused and will need " +
|
|
283
|
+
"reflashing. Nodes that have already enrolled are unaffected - they never present it again."))) return;
|
|
239
284
|
DELETE(`/enrolment_secret/${this.state.org}`, {secret}, (err, json) => {
|
|
240
285
|
this.message(err ? err.message : (json && json.message) || getString("Withdrawn"), false);
|
|
241
286
|
this.getEnrolmentSecrets();
|
|
@@ -358,7 +403,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
358
403
|
: { label: "DOWN", hint: "The broker reports this bridge not connected" };
|
|
359
404
|
return el('tr', {}, [
|
|
360
405
|
el('td', {i8n: false, textContent: b.site}),
|
|
361
|
-
el('td', {i8n: false, title: getString(up.hint), textContent: up.label}),
|
|
406
|
+
el('td', {i8n: false, title: getString(up.hint), textContent: getString(up.label)}),
|
|
362
407
|
el('td', {i8n: false, textContent: b.last_pull ? this.formatLastSeen(new Date(b.last_pull).toISOString()) : getString("Never seen")}),
|
|
363
408
|
el('td', {i8n: false, textContent: b.created_at ? this.formatLastSeen(new Date(b.created_at).toISOString()) : ''}),
|
|
364
409
|
]);
|
|
@@ -379,10 +424,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
379
424
|
if (!site) { this.message(getString("Give the Pi a short name first")); return; }
|
|
380
425
|
if (this.state.bridges && this.state.bridges.some((b) => b.site === site)
|
|
381
426
|
&& !window.confirm(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
427
|
+
getStringWith('"%s" already exists.', site) + "\n\n" +
|
|
428
|
+
getString("Adding it again issues a new broker password and a new replica token, and the " +
|
|
429
|
+
"ones that Pi is using now stop working. It will stay disconnected until you put the " +
|
|
430
|
+
"new ones into its configuration and restart its broker."))) return;
|
|
386
431
|
POST(`/bridge_add/${this.state.org}`, { site }, (err, json) => {
|
|
387
432
|
if (err) { this.message(err.message); return; }
|
|
388
433
|
this.state.new_bridge = json; // shown once, below
|
|
@@ -399,23 +444,24 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
399
444
|
if (!b) return el('span', {});
|
|
400
445
|
return el('div', {class: 'bridge-new'}, [
|
|
401
446
|
el('h4', {textContent: "Copy these now - they are not shown again"}),
|
|
402
|
-
el('
|
|
447
|
+
// el() will not translate a string containing ':' or '/', so ask for it directly
|
|
448
|
+
el('p', {i8n: false, textContent: getString(
|
|
403
449
|
"Run this on the Pi, in the directory the server is installed in. Replace the placeholder " +
|
|
404
450
|
"with this server's fully-qualified name - the one on its certificate, which you can check " +
|
|
405
451
|
"by opening https:// that name in a browser. It will then ask for the broker password and " +
|
|
406
|
-
"for the replica token, in that order, so have both of the values below to hand."}),
|
|
452
|
+
"for the replica token, in that order, so have both of the values below to hand.")}),
|
|
407
453
|
el('pre', {i8n: false, textContent: b.command}),
|
|
408
454
|
el('table', {class: 'nodestates'}, [
|
|
409
455
|
el('tr', {}, [el('th', {textContent: "Broker account"}), el('td', {i8n: false, textContent: b.account})]),
|
|
410
456
|
el('tr', {}, [el('th', {textContent: "Broker password"}), el('td', {i8n: false, textContent: b.password})]),
|
|
411
457
|
el('tr', {}, [el('th', {textContent: "Replica token"}), el('td', {i8n: false, textContent: b.replica_token})]),
|
|
412
458
|
]),
|
|
413
|
-
el('p', {textContent:
|
|
459
|
+
el('p', {i8n: false, textContent: getString(
|
|
414
460
|
"Two credentials for two different connections: the password is what the MQTT bridge logs " +
|
|
415
461
|
"in with on port 8883, and the token is what lets that Pi pull this organization's logins " +
|
|
416
462
|
"over HTTPS, so the same people can log in there too. The token can be skipped - press " +
|
|
417
463
|
"Enter at its prompt - and added later by doing this again. Neither is asked for on the " +
|
|
418
|
-
"command line, so that neither ends up in the Pi's shell history."}),
|
|
464
|
+
"command line, so that neither ends up in the Pi's shell history.")}),
|
|
419
465
|
]);
|
|
420
466
|
}
|
|
421
467
|
bridgesRestContent() {
|
|
@@ -435,7 +481,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
435
481
|
"without reconfiguring that Pi. Lower-case letters, digits and hyphens."}),
|
|
436
482
|
el('div', {}, [
|
|
437
483
|
this.state.elements.bridge_site = el('input',
|
|
438
|
-
{type: 'text', name: 'site', placeholder: 'pi4', size: 16}),
|
|
484
|
+
{i8n: false, type: 'text', name: 'site', placeholder: 'pi4', size: 16}),
|
|
439
485
|
el('button', {textContent: "Add", onclick: this.onBridgeAdd.bind(this)}),
|
|
440
486
|
]),
|
|
441
487
|
this.state.elements.bridge_new = this.newBridgeDetails(),
|
|
@@ -447,10 +493,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
447
493
|
// credential being refused and enrols again) but it does cost that node a few minutes offline.
|
|
448
494
|
// Worth asking, unlike the OTA deletes beside it where the file can simply be uploaded again.
|
|
449
495
|
if (!window.confirm(
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
496
|
+
getStringWith("Forget %s?", `${project}/${nodeid}`) + "\n\n" +
|
|
497
|
+
getString("It will be issued a new broker credential the next time it asks. A node that is " +
|
|
498
|
+
"running will be offline for a minute or two while it notices; a node whose filesystem " +
|
|
499
|
+
"was erased needs this before it can rejoin at all."))) return;
|
|
454
500
|
POST(`/node_reset/${this.state.org}`, { project, nodeid }, (err, json) => {
|
|
455
501
|
this.message(err ? err.message : (json && json.message) || "Forgotten");
|
|
456
502
|
this.getEnrolledNodes();
|
|
@@ -496,9 +542,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
496
542
|
}
|
|
497
543
|
onNodeState(project, nodeid, state) {
|
|
498
544
|
if (state === 'denied' && !window.confirm(
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
545
|
+
getStringWith("Deny %s?", nodeid) + "\n\n" +
|
|
546
|
+
getString("Its broker account is deleted, so it stops being able to publish or subscribe, " +
|
|
547
|
+
"and it cannot enrol again until you clear this. Use it for a node sending bad readings."))) return;
|
|
502
548
|
POST(`/node_state/${this.state.org}`, {project, nodeid, state}, (err, json) => {
|
|
503
549
|
this.message(err ? err.message : (json && json.message) || getString("Done"), false);
|
|
504
550
|
this.getEnrolledNodes();
|
|
@@ -1387,7 +1433,8 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1387
1433
|
// Content of the OTA tab below the organization dropdown - only rendered once an org is selected
|
|
1388
1434
|
otaRestContent() {
|
|
1389
1435
|
return el('div', {}, [
|
|
1390
|
-
el('form', {action: '/ota_update', method: "post", enctype: "multipart/form-data"
|
|
1436
|
+
el('form', {action: '/ota_update', method: "post", enctype: "multipart/form-data",
|
|
1437
|
+
onsubmit: this.onOtaUpload.bind(this)}, [
|
|
1391
1438
|
el('input', {id: "url2", name: "url", type: "hidden", value: `/dashboard/`}),
|
|
1392
1439
|
el('input', {id: "lang", name: "lang", type: "hidden", value: preferedLanguages.join(',')}),
|
|
1393
1440
|
// The organization dropdown for this tab is a sibling of this form rather than inside it
|
|
@@ -1421,9 +1468,6 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1421
1468
|
this.refreshableHeading("Existing OTA Files", this.getOtaFiles),
|
|
1422
1469
|
this.state.elements.ota_files = this.otaFilesList(),
|
|
1423
1470
|
]), // section ota
|
|
1424
|
-
el('section', {}, [
|
|
1425
|
-
this.state.section ? null : (this.state.elements.flash = el('mqtt-flash', {})), // its own card on the project back
|
|
1426
|
-
]), // section flash
|
|
1427
1471
|
]);
|
|
1428
1472
|
}
|
|
1429
1473
|
// Content of the Admin tab below the organization dropdown - only rendered once an org is selected
|
|
@@ -1661,6 +1705,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1661
1705
|
return el('div', {class: 'mqtt-admin'}, [
|
|
1662
1706
|
this.state.org ? null
|
|
1663
1707
|
: (this.state.elements[section.dropdown] = el('span', {textContent: "Waiting"})),
|
|
1708
|
+
// message() had nowhere to write until now, so every failed fetch reported itself only to
|
|
1709
|
+
// the console. One line per card, above whatever that card is saying.
|
|
1710
|
+
this.state.elements.message = el('div', {class: 'message', i8n: false,
|
|
1711
|
+
textContent: this.state.message || ''}),
|
|
1664
1712
|
this.state.elements[section.rest] = this.gatedContent(section),
|
|
1665
1713
|
]);
|
|
1666
1714
|
}
|
package/cards.js
CHANGED
|
@@ -748,6 +748,25 @@ class MqttProjectBack extends HTMLElementExtendedMinimum {
|
|
|
748
748
|
super();
|
|
749
749
|
this.state.elements = {};
|
|
750
750
|
this.state.open = {};
|
|
751
|
+
this.onFlashFile = this.onFlashFile.bind(this);
|
|
752
|
+
}
|
|
753
|
+
connectedCallback() {
|
|
754
|
+
super.connectedCallback();
|
|
755
|
+
document.addEventListener('frugaliot:flashfile', this.onFlashFile);
|
|
756
|
+
}
|
|
757
|
+
disconnectedCallback() {
|
|
758
|
+
document.removeEventListener('frugaliot:flashfile', this.onFlashFile);
|
|
759
|
+
if (super.disconnectedCallback) super.disconnectedCallback();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// The OTA card asks; only this knows where the flasher is, and whether its card is open yet.
|
|
763
|
+
onFlashFile(e) {
|
|
764
|
+
const card = this.state.elements.flash;
|
|
765
|
+
if (!card) return; // No OTAFLASH here, so no card - the OTA list should not have offered it
|
|
766
|
+
if (!this.state.open.flash) this.toggle('flash');
|
|
767
|
+
const adminEl = card.querySelector('mqtt-admin');
|
|
768
|
+
if (adminEl) adminEl.flashRemote(e.detail.org, e.detail.path);
|
|
769
|
+
if (card.scrollIntoView) card.scrollIntoView({behavior: 'smooth', block: 'start'}); // jsdom has none
|
|
751
770
|
}
|
|
752
771
|
|
|
753
772
|
// An admin card has a summary and a back, and nothing that makes sense as a front: it is its name
|