frugal-iot-client 2.0.2 → 2.0.4

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,13 +403,67 @@ 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
  ]);
365
410
  }),
366
411
  ]);
367
412
  }
413
+ /*
414
+ * Add a bridge, or re-issue an existing one's credentials.
415
+ *
416
+ * The result is shown once and is not stored anywhere it can be read back: the password is
417
+ * derived from a server secret and this row's timestamp, so the only copy is the one you put into
418
+ * the Pi's configuration. Re-running for the same site mints new ones and revokes the old, which
419
+ * is what makes it the way to recover from having lost them.
420
+ */
421
+ onBridgeAdd() {
422
+ const input = this.state.elements.bridge_site;
423
+ const site = (input && input.value || '').trim();
424
+ if (!site) { this.message(getString("Give the Pi a short name first")); return; }
425
+ if (this.state.bridges && this.state.bridges.some((b) => b.site === site)
426
+ && !window.confirm(
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;
431
+ POST(`/bridge_add/${this.state.org}`, { site }, (err, json) => {
432
+ if (err) { this.message(err.message); return; }
433
+ this.state.new_bridge = json; // shown once, below
434
+ this.getBridges(); // refreshes the table and re-renders the card
435
+ this.replaceElement("bridge_new", this.newBridgeDetails());
436
+ });
437
+ }
438
+ /*
439
+ * What the Pi needs, once. Deliberately not fetchable again: there is no route that reads a
440
+ * bridge's password back, so losing it means issuing a new one.
441
+ */
442
+ newBridgeDetails() {
443
+ const b = this.state.new_bridge;
444
+ if (!b) return el('span', {});
445
+ return el('div', {class: 'bridge-new'}, [
446
+ el('h4', {textContent: "Copy these now - they are not shown again"}),
447
+ // el() will not translate a string containing ':' or '/', so ask for it directly
448
+ el('p', {i8n: false, textContent: getString(
449
+ "Run this on the Pi, in the directory the server is installed in. Replace the placeholder " +
450
+ "with this server's fully-qualified name - the one on its certificate, which you can check " +
451
+ "by opening https:// that name in a browser. It will then ask for the broker password and " +
452
+ "for the replica token, in that order, so have both of the values below to hand.")}),
453
+ el('pre', {i8n: false, textContent: b.command}),
454
+ el('table', {class: 'nodestates'}, [
455
+ el('tr', {}, [el('th', {textContent: "Broker account"}), el('td', {i8n: false, textContent: b.account})]),
456
+ el('tr', {}, [el('th', {textContent: "Broker password"}), el('td', {i8n: false, textContent: b.password})]),
457
+ el('tr', {}, [el('th', {textContent: "Replica token"}), el('td', {i8n: false, textContent: b.replica_token})]),
458
+ ]),
459
+ el('p', {i8n: false, textContent: getString(
460
+ "Two credentials for two different connections: the password is what the MQTT bridge logs " +
461
+ "in with on port 8883, and the token is what lets that Pi pull this organization's logins " +
462
+ "over HTTPS, so the same people can log in there too. The token can be skipped - press " +
463
+ "Enter at its prompt - and added later by doing this again. Neither is asked for on the " +
464
+ "command line, so that neither ends up in the Pi's shell history.")}),
465
+ ]);
466
+ }
368
467
  bridgesRestContent() {
369
468
  return el('div', {}, [
370
469
  this.refreshableHeading("Bridged Pis", this.getBridges),
@@ -375,6 +474,18 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
375
474
  "this organization's logins, which it does over HTTP - so a Pi can be checking in while " +
376
475
  "its bridge is down, and the two together say which half is broken."}),
377
476
  this.state.elements.bridges_list = this.bridgesList(),
477
+ el('section', {}, [
478
+ el('h3', {textContent: "Add a bridge"}),
479
+ el('p', {textContent:
480
+ "Give the Pi a short name - it becomes its broker account and cannot be changed later " +
481
+ "without reconfiguring that Pi. Lower-case letters, digits and hyphens."}),
482
+ el('div', {}, [
483
+ this.state.elements.bridge_site = el('input',
484
+ {i8n: false, type: 'text', name: 'site', placeholder: 'pi4', size: 16}),
485
+ el('button', {textContent: "Add", onclick: this.onBridgeAdd.bind(this)}),
486
+ ]),
487
+ this.state.elements.bridge_new = this.newBridgeDetails(),
488
+ ]),
378
489
  ]);
379
490
  }
380
491
  onNodeReset(project, nodeid) {
@@ -382,10 +493,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
382
493
  // credential being refused and enrols again) but it does cost that node a few minutes offline.
383
494
  // Worth asking, unlike the OTA deletes beside it where the file can simply be uploaded again.
384
495
  if (!window.confirm(
385
- `Forget ${project}/${nodeid}?\n\n` +
386
- `It will be issued a new broker credential the next time it asks. A node that is running ` +
387
- `will be offline for a minute or two while it notices; a node whose filesystem was erased ` +
388
- `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;
389
500
  POST(`/node_reset/${this.state.org}`, { project, nodeid }, (err, json) => {
390
501
  this.message(err ? err.message : (json && json.message) || "Forgotten");
391
502
  this.getEnrolledNodes();
@@ -431,9 +542,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
431
542
  }
432
543
  onNodeState(project, nodeid, state) {
433
544
  if (state === 'denied' && !window.confirm(
434
- `Deny ${nodeid}?\n\n` +
435
- `Its broker account is deleted, so it stops being able to publish or subscribe, and it ` +
436
- `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;
437
548
  POST(`/node_state/${this.state.org}`, {project, nodeid, state}, (err, json) => {
438
549
  this.message(err ? err.message : (json && json.message) || getString("Done"), false);
439
550
  this.getEnrolledNodes();
@@ -1322,7 +1433,8 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1322
1433
  // Content of the OTA tab below the organization dropdown - only rendered once an org is selected
1323
1434
  otaRestContent() {
1324
1435
  return el('div', {}, [
1325
- 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)}, [
1326
1438
  el('input', {id: "url2", name: "url", type: "hidden", value: `/dashboard/`}),
1327
1439
  el('input', {id: "lang", name: "lang", type: "hidden", value: preferedLanguages.join(',')}),
1328
1440
  // The organization dropdown for this tab is a sibling of this form rather than inside it
@@ -1356,9 +1468,6 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1356
1468
  this.refreshableHeading("Existing OTA Files", this.getOtaFiles),
1357
1469
  this.state.elements.ota_files = this.otaFilesList(),
1358
1470
  ]), // section ota
1359
- el('section', {}, [
1360
- this.state.section ? null : (this.state.elements.flash = el('mqtt-flash', {})), // its own card on the project back
1361
- ]), // section flash
1362
1471
  ]);
1363
1472
  }
1364
1473
  // Content of the Admin tab below the organization dropdown - only rendered once an org is selected
@@ -1596,6 +1705,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1596
1705
  return el('div', {class: 'mqtt-admin'}, [
1597
1706
  this.state.org ? null
1598
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 || ''}),
1599
1712
  this.state.elements[section.rest] = this.gatedContent(section),
1600
1713
  ]);
1601
1714
  }
package/cards.js CHANGED
@@ -733,6 +733,7 @@ const ADMIN_CARDS = [
733
733
  { section: 'message', title: 'Publish Message', capability: 'ADMIN' },
734
734
  { section: 'retained', title: 'Retained messages', capability: 'ADMIN' },
735
735
  { section: 'nodes', title: 'Nodes', capability: 'ADMIN' },
736
+ { section: 'bridges', title: 'Bridges', capability: 'ADMIN' },
736
737
  { section: 'api', title: 'API', capability: 'ADMIN' },
737
738
  ];
738
739
 
@@ -747,6 +748,25 @@ class MqttProjectBack extends HTMLElementExtendedMinimum {
747
748
  super();
748
749
  this.state.elements = {};
749
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
750
770
  }
751
771
 
752
772
  // An admin card has a summary and a back, and nothing that makes sense as a front: it is its name