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 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 three 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
+ 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
- const flashEl = this.state.elements.flash;
186
- if (flashEl) flashEl.setRemoteSource(this.state.org, path);
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
- el('span', {class: 'pseudolink', title: getString("Flash this over USB"), textContent: '⚡', onclick: this.onOtaFlash.bind(this, f)}),
193
- el('span', {class: 'pseudolink', textContent: `🗑 ${f}`, onclick: this.onOtaDelete.bind(this,`${this.state.org}/${f}`)}),
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
- `Withdraw this enrolment secret?\n\n` +
237
- `Any node flashed with it that has not enrolled yet will be refused and will need ` +
238
- `reflashing. Nodes that have already enrolled are unaffected - they never present it again.`)) return;
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
- `"${site}" already exists.\n\n` +
383
- `Adding it again issues a new broker password and a new replica token, and the ones that ` +
384
- `Pi is using now stop working. It will stay disconnected until you put the new ones into ` +
385
- `its configuration and restart its broker.`)) return;
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('p', {textContent:
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
- `Forget ${project}/${nodeid}?\n\n` +
451
- `It will be issued a new broker credential the next time it asks. A node that is running ` +
452
- `will be offline for a minute or two while it notices; a node whose filesystem was erased ` +
453
- `needs this before it can rejoin at all.`)) return;
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
- `Deny ${nodeid}?\n\n` +
500
- `Its broker account is deleted, so it stops being able to publish or subscribe, and it ` +
501
- `cannot enrol again until you clear this. Use it for a node sending bad readings.`)) return;
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