frugal-iot-client 2.0.0 → 2.0.2

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
@@ -48,6 +48,10 @@ these by bare filename (`see CARDS_UX.md 4.7`); they all live in `docs/` except
48
48
  - **Comments**: explain WHY, not WHAT. One short line is enough; do not restate what the code clearly shows. No multi-line docstrings.
49
49
  - Good: `// topicSetPath throws when this.node is null — guard before calling`
50
50
  - Bad: `// Check if topic starts with the set path`
51
+ - **A comment says what is true now, not what used to break.** When fixing a bug, state the
52
+ constraint that still holds; the diagnosis — what it threw, where, why one deployment differed —
53
+ belongs in the pull request or the chat, not in the source, where it buries the one line that
54
+ still matters.
51
55
  - **No trailing summaries** — do not add a comment block or prose after a method explaining what it does.
52
56
  - **Getters**: prefer `get foo()` over plain property access whenever a value is derived or retrieved by traversing connected objects (e.g. `this.node.project`, `this.parentElement`, `this.mt.node.groups[this.group]`). Getters make the dependency chain explicit and let callers read `thing.project` naturally.
53
57
  - **Dashboard functionality**: prefer adding reusable behaviour to the shared modules over implementing it inside a custom dashboard file. If a dashboard needs to traverse the topic tree, compute a list, or fire an event, that logic belongs on the relevant class as a getter or method.
package/admin.js CHANGED
@@ -7,7 +7,7 @@
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, configSet, el, getString, hasCapability, mqttTempConnect, retainedPattern, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
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';
11
11
 
12
12
 
13
13
  // ---------- USB flashing over WebSerial ----------
@@ -36,10 +36,22 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
36
36
  }
37
37
  //this.append(el('div', {class: 'message', textContent: msg}));
38
38
  }
39
+ // Only organizations server_config holds: a permission row can name one this server does not
40
+ // serve, and no card could act on it. Keyed by id so an organization-wide and a project-scoped
41
+ // row for the same capability list it once.
39
42
  orgsByPerm(capability) {
40
- return server_config.user.permissions
43
+ const orgs = new Map();
44
+ server_config.user.permissions
41
45
  .filter(o => o.capability === capability)
42
- .map(o => [ o.org, server_config.organizations[o.org].name ])
46
+ .forEach(o => {
47
+ const org = server_config.organizations[o.org];
48
+ if (org) orgs.set(o.org, org.name);
49
+ });
50
+ return [...orgs];
51
+ }
52
+ // An organization gains its projects block only once one exists, so this is not always there.
53
+ projectsOf(org) {
54
+ return (server_config.organizations[org] || {}).projects || {};
43
55
  }
44
56
  get adminOrgs() {
45
57
  return this.orgsByPerm("ADMIN");
@@ -102,7 +114,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
102
114
  return el('select', {id: 'projects', name: 'project' /*onchange: this.onOrganization.bind(this)*/}, [
103
115
  //el('option', {value: "", textContent: "Not selected", selected: !this.state.value}),
104
116
  el('option', {value: "+", textContent: "All", selected: true}),
105
- Object.entries(server_config.organizations[org].projects)
117
+ Object.entries(this.projectsOf(org))
106
118
  .map(([pid, p]) => [ pid, p.name ])
107
119
  .map(([pid, name]) =>
108
120
  el('option', {value: pid, textContent: `${pid}: ${name}`, selected: false}))
@@ -238,18 +250,37 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
238
250
  ]);
239
251
  }
240
252
  return el('div', {}, [
241
- ...(e.secrets.length ? e.secrets.map((secret, i) => el('p', {}, [
242
- el('span', {class: 'pseudolink', textContent: ' 🗑 ',
243
- onclick: this.onEnrolmentSecretDelete.bind(this, secret)}),
244
- // The line to paste into a sketch, so nobody has to work out the argument order
245
- el('code', {i8n: false, class: 'enrolment__line', textContent:
246
- `frugal_iot.configure_mqtt_enrolled("${(server_config.mqtt && server_config.mqtt.broker) || ''}", "${secret}");`}),
247
- ])) : [el('p', {textContent:
253
+ ...(e.secrets.length ? [
254
+ el('p', {textContent: "Put this in platformio-secrets-local.ini, which is not committed:"}),
255
+ ...e.secrets.map((secret) => el('p', {}, [
256
+ el('span', {class: 'pseudolink', textContent: ' 🗑 ',
257
+ onclick: this.onEnrolmentSecretDelete.bind(this, secret)}),
258
+ el('code', {i8n: false, class: 'enrolment__line', textContent:
259
+ `'-D SYSTEM_MQTT_ENROL_SECRET="${secret}"'`}),
260
+ ])),
261
+ /*
262
+ * The host, and this server's own enrolment URL, for anyone building against something
263
+ * other than the production server the firmware defaults to.
264
+ *
265
+ * SYSTEM_MQTT_HOST is a HOST, derived from the broker URL rather than being it: that URL is
266
+ * the WebSocket address a browser uses through the reverse proxy, while the firmware
267
+ * resolves this string as a DNS name and connects to 1883. Handing over the URL gives
268
+ * "DNS Failed ... -54" on a node that has already enrolled, so it reads as an enrolment
269
+ * fault and is not one.
270
+ */
271
+ el('p', {textContent:
272
+ "And these two, if you are not building against the production server:"}),
273
+ el('p', {}, [
274
+ el('code', {i8n: false, class: 'enrolment__line', textContent:
275
+ `'-D SYSTEM_MQTT_HOST="${brokerHost(server_config.mqtt && server_config.mqtt.broker)}"'`}),
276
+ el('br', {}),
277
+ el('code', {i8n: false, class: 'enrolment__line', textContent:
278
+ `'-D SYSTEM_MQTT_ENROL_URL="${e.enrol_url}"'`}),
279
+ ]),
280
+ ] : [el('p', {textContent:
248
281
  "This organization has no enrolment secret, so no new node can enrol. Add one."})]),
249
282
  el('button', {class: 'submit', type: 'button', textContent: "Add another enrolment secret",
250
283
  onclick: this.onEnrolmentSecretAdd.bind(this)}),
251
- el('p', {i8n: false, class: 'retained__help', textContent:
252
- `${getString("Enrol URL")}: ${e.enrol_url}`}),
253
284
  ]);
254
285
  }
255
286
  getEnrolledNodes() {
@@ -260,6 +291,92 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
260
291
  this.replaceElement("enrolled_nodes", this.enrolledNodesList());
261
292
  });
262
293
  }
294
+ /*
295
+ * The Pis bridging to this server, and whether each is currently connected.
296
+ *
297
+ * Two independent answers, which is the point of showing both. "Last check-in" is the server's
298
+ * own record of that Pi collecting replicated logins, so it says the Pi is alive and talking
299
+ * HTTP. "Bridge" is the broker's retained state for that account, so it says the MQTT relay
300
+ * itself is up. A Pi with a broken bridge still checks in, and that difference is exactly what
301
+ * is hard to see from production any other way.
302
+ */
303
+ getBridges() {
304
+ if (!this.state.org) return;
305
+ GET(`/bridges_list/${this.state.org}`, {}, (err, json) => {
306
+ if (err) { this.message(err.message); return; }
307
+ this.state.bridges = json; // [{ site, account, created_at, last_pull }]
308
+ this.subscribeBridgeState();
309
+ this.replaceElement("bridges_list", this.bridgesList());
310
+ });
311
+ }
312
+ /*
313
+ * Watch each bridge's connection state.
314
+ *
315
+ * One subscription per bridge rather than the "+" wildcard, because mqtt_deliver matches an
316
+ * incoming topic with topicMatches, which understands a trailing "/#" and nothing else - a "+" in
317
+ * the middle is compared literally, so the broker would send the messages and none would ever be
318
+ * delivered to this callback. Exact topics are narrower anyway, and the retained value arrives
319
+ * immediately, so a state appears without waiting for anything to change.
320
+ *
321
+ * Tracked in a Set because mqtt_subscribe does not de-duplicate: without this, every refresh and
322
+ * every organization change would add another copy of the same subscription.
323
+ */
324
+ subscribeBridgeState() {
325
+ this.state.bridge_state = this.state.bridge_state || {};
326
+ this.state.bridge_subscribed = this.state.bridge_subscribed || new Set();
327
+ for (const b of (this.state.bridges || [])) {
328
+ const topic = `$SYS/broker/connection/${b.account}/state`;
329
+ if (this.state.bridge_subscribed.has(topic)) continue;
330
+ this.state.bridge_subscribed.add(topic);
331
+ // (topic, msg, retained) - the first argument is the topic, not the message
332
+ mqtt_subscribe(topic, (_topic, msg) => {
333
+ this.state.bridge_state[b.account] = String(msg);
334
+ this.replaceElement("bridges_list", this.bridgesList());
335
+ });
336
+ }
337
+ }
338
+ bridgesList() {
339
+ const bridges = this.state.bridges;
340
+ if (!bridges || !bridges.length) {
341
+ return el('p', {textContent:
342
+ "No Pi bridges to this server for this organization. A bridge relays a Pi's readings here " +
343
+ "while the Pi carries on working on its own - see INSTALLATION.md step 11."});
344
+ }
345
+ const state = this.state.bridge_state || {};
346
+ return el('table', {class: 'nodestates'}, [
347
+ el('tr', {}, [
348
+ el('th', {textContent: "Site"}), el('th', {textContent: "Bridge"}),
349
+ el('th', {textContent: "Last check-in"}), el('th', {textContent: "Added"}),
350
+ ]),
351
+ ...bridges.map((b) => {
352
+ // Three states, not two: nothing received yet is not the same as "down", and saying
353
+ // "Unknown" avoids reporting a healthy bridge as broken while the retained message is still
354
+ // in flight or when this login has not been granted the $SYS topic.
355
+ const raw = state[b.account];
356
+ const up = (raw === undefined) ? { label: "Unknown", hint: "No state received from the broker yet" }
357
+ : (raw === '1') ? { label: "Up", hint: "The broker reports this bridge connected" }
358
+ : { label: "DOWN", hint: "The broker reports this bridge not connected" };
359
+ return el('tr', {}, [
360
+ el('td', {i8n: false, textContent: b.site}),
361
+ el('td', {i8n: false, title: getString(up.hint), textContent: up.label}),
362
+ el('td', {i8n: false, textContent: b.last_pull ? this.formatLastSeen(new Date(b.last_pull).toISOString()) : getString("Never seen")}),
363
+ el('td', {i8n: false, textContent: b.created_at ? this.formatLastSeen(new Date(b.created_at).toISOString()) : ''}),
364
+ ]);
365
+ }),
366
+ ]);
367
+ }
368
+ bridgesRestContent() {
369
+ return el('div', {}, [
370
+ this.refreshableHeading("Bridged Pis", this.getBridges),
371
+ el('p', {textContent:
372
+ "A bridge relays a Pi's readings to this server, so its nodes appear here too, while the " +
373
+ "Pi carries on recording and serving on its own whenever the link is down. \"Bridge\" is " +
374
+ "the broker's live view of the relay; \"Last check-in\" is when that Pi last collected " +
375
+ "this organization's logins, which it does over HTTP - so a Pi can be checking in while " +
376
+ "its bridge is down, and the two together say which half is broken."}),
377
+ this.state.elements.bridges_list = this.bridgesList(),
378
+ ]);
379
+ }
263
380
  onNodeReset(project, nodeid) {
264
381
  // Destructive and not obvious: forgetting a node that is running is safe (it notices its
265
382
  // credential being refused and enrols again) but it does cost that node a few minutes offline.
@@ -284,10 +401,32 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
284
401
  */
285
402
  nodeStates() {
286
403
  return {
287
- enrolled: { label: "Yes", next: 'denied', hint: "Has its own broker credential. Click to deny it." },
288
- failed: { label: "Failed", next: 'approved', hint: "Asked and was refused. Click to let it enrol." },
289
- approved: { label: "Approved", next: 'denied', hint: "Its next request will be accepted. Click to deny it." },
290
- denied: { label: "Denied", next: 'cleared', hint: "Stopped, and its credential removed. Click to clear." },
404
+ enrolled: { label: "Yes", next: 'denied', hint: "Has its own broker credential. Click to deny it." },
405
+ // "Failed" alone read as a status rather than a control, so the one action this card exists to
406
+ // offer - approving a node that cannot prove anything - was not findable by someone looking
407
+ // for it. The label says what clicking does.
408
+ failed: { label: "Failed - approve", next: 'approved', hint: "Asked and was refused. Click to let it enrol." },
409
+ approved: { label: "Approved", next: 'denied', hint: "Its next request will be accepted. Click to deny it." },
410
+ denied: { label: "Denied", next: 'cleared', hint: "Stopped, and its credential removed. Click to clear." },
411
+ };
412
+ }
413
+ /*
414
+ * Why the server turned a node away, in words rather than the wire code.
415
+ *
416
+ * Worth the table: the codes are deliberately vague to the NODE (so that probing an organization
417
+ * name learns nothing), but an administrator looking at their own organization needs the
418
+ * difference - "wrong secret" and "project not registered" have completely different fixes, and
419
+ * neither is fixed by approving the node.
420
+ */
421
+ nodeAskReasons() {
422
+ return {
423
+ refused: "enrolment secret wrong or missing",
424
+ no_such_project: "project is not registered here",
425
+ bad_request: "malformed org, project or node id",
426
+ needs_reset: "already enrolled, cannot prove it",
427
+ denied: "denied here",
428
+ rate_limited: "asking too often",
429
+ broker_unavailable: "broker was unavailable",
291
430
  };
292
431
  }
293
432
  onNodeState(project, nodeid, state) {
@@ -304,7 +443,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
304
443
  const nodes = this.state.enrolled_nodes;
305
444
  if (!nodes || !nodes.length) {
306
445
  return el('p', {textContent:
307
- "No nodes have enrolled yet. A node enrols itself the first time it connects."});
446
+ "No nodes have enrolled yet, and none is asking to. A node enrols itself the first time it " +
447
+ "connects; one that asks and is refused is listed here too, so an empty list means nothing " +
448
+ "is reaching this server at all - check that the node's org and enrol URL are this one."});
308
449
  }
309
450
  const states = this.nodeStates();
310
451
  return el('table', {class: 'nodestates'}, [
@@ -322,10 +463,14 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
322
463
  el('span', {class: 'pseudolink', title: getString(st.hint), textContent: st.label,
323
464
  onclick: this.onNodeState.bind(this, n.project, n.nodeid, st.next)}),
324
465
  ]),
325
- // Untrusted, and shown as such: an address and a time for the admin to recognise
466
+ // Untrusted, and shown as such: an address and a time for the admin to recognise. The
467
+ // reason is here too - without it every refusal looks the same, and the ones an admin can
468
+ // do something about (a project that is not registered, a node id that is not a node id)
469
+ // are indistinguishable from the one that approving fixes.
326
470
  el('td', {i8n: false, textContent: n.asked_at
327
471
  ? `${this.formatLastSeen(new Date(n.asked_at).toISOString())} from ${n.asked_from || '?'}` +
328
- `${n.asked_count > 1 ? ` (${n.asked_count}x)` : ''}`
472
+ `${n.asked_count > 1 ? ` (${n.asked_count}x)` : ''}` +
473
+ `${n.asked_reason ? ` - ${this.nodeAskReasons()[n.asked_reason] || n.asked_reason}` : ''}`
329
474
  : (n.enrolled_at ? new Date(n.enrolled_at).toISOString().slice(0, 10) : '')}),
330
475
  el('td', {}, [
331
476
  n.enrolled_at
@@ -911,6 +1056,24 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
911
1056
  }
912
1057
  return this.state.elements[name];
913
1058
  }
1059
+ // Each of these lists is the result of one fetch made when the card opened - nothing pushes a
1060
+ // change, so the heading carries the way to ask again.
1061
+ refreshableHeading(title, refreshFn) {
1062
+ return el('h3', {}, [
1063
+ el('span', {textContent: title}),
1064
+ el('span', {class: 'pseudolink refresh', i8n: false, title: getString("Refresh"),
1065
+ textContent: ' \u27f3', onclick: refreshFn.bind(this)}),
1066
+ ]);
1067
+ }
1068
+ // The organization's nodes are read out of server_config, so refreshing this table means fetching
1069
+ // /config.json again rather than re-rendering what is already here.
1070
+ refreshNodesTable() {
1071
+ GET("/config.json", {}, (err, json) => {
1072
+ if (err) { this.message(err.message); return; }
1073
+ configSet(json);
1074
+ this.replaceElement("nodes_table", this.nodesTable());
1075
+ });
1076
+ }
914
1077
  setOrganization(org) {
915
1078
  this.state.org = org;
916
1079
  this.state.selected_platform_id = null;
@@ -933,7 +1096,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
933
1096
  // the enrolled-node list as well as reading server_config - leaving it out was why that section
934
1097
  // always said "No nodes have enrolled yet": loadSectionIfNeeded returns early for a tab that is not
935
1098
  // in this set, so the fetch never happened.
936
- this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'API']);
1099
+ this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'Bridges', 'API']);
937
1100
  this.loadSectionIfNeeded(this.state.activeSectionTitle);
938
1101
  // Only a card created without an organization has a dropdown of its own; replaceElement does
939
1102
  // nothing for an element this rendering never made. Fine too if this.state.org is undefined.
@@ -971,6 +1134,8 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
971
1134
  this.getProjectsList();
972
1135
  } else if (title === 'Nodes') {
973
1136
  this.getEnrolledNodes();
1137
+ } else if (title === 'Bridges') {
1138
+ this.getBridges();
974
1139
  } else if (title === 'API') {
975
1140
  this.getPlatformsList();
976
1141
  this.getFarmsList();
@@ -1004,12 +1169,12 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1004
1169
 
1005
1170
  // Flattens server_config's nodes-by-project data for one org into the row shape nodesTableFor() renders.
1006
1171
  nodesForOrg(org) {
1007
- const projects = (server_config.organizations[org] || {}).projects || {};
1172
+ const projects = this.projectsOf(org);
1008
1173
  return this.nodesForProjects(org, Object.keys(projects));
1009
1174
  }
1010
1175
  // Same, but restricted to a specific set of project ids (e.g. those api_farms maps to a farm).
1011
1176
  nodesForProjects(org, projectIds) {
1012
- const projects = (server_config.organizations[org] || {}).projects || {};
1177
+ const projects = this.projectsOf(org);
1013
1178
  let nodes = [];
1014
1179
  projectIds.forEach(projectId => {
1015
1180
  const project = projects[projectId];
@@ -1109,7 +1274,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1109
1274
  // Create dropdown for selecting a different project for a node
1110
1275
  if (!org) { return el('span', {textContent: "No organization"}); }
1111
1276
  return el('select', {name: 'project'}, [
1112
- Object.entries(server_config.organizations[org].projects)
1277
+ Object.entries(this.projectsOf(org))
1113
1278
  .map(([pid, p]) => [ pid, p.name ])
1114
1279
  .map(([pid, name]) =>
1115
1280
  el('option', {value: pid, textContent: `${pid}: ${name}`, selected: pid === currentProjectId}))
@@ -1188,7 +1353,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1188
1353
  el('button', {class: "submit", type: "submit", textContent: 'Upload'}),
1189
1354
  ]), //form
1190
1355
  el('section', {}, [
1191
- el('h3', {textContent: "Existing OTA Files"}),
1356
+ this.refreshableHeading("Existing OTA Files", this.getOtaFiles),
1192
1357
  this.state.elements.ota_files = this.otaFilesList(),
1193
1358
  ]), // section ota
1194
1359
  el('section', {}, [
@@ -1200,7 +1365,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1200
1365
  adminRestContent() {
1201
1366
  return el('div', {}, [
1202
1367
  el('section', {}, [
1203
- el('h3', {textContent: "Permissions"}),
1368
+ this.refreshableHeading("Permissions", this.getPeopleList),
1204
1369
  // List of people and their permissions, with option to delete,
1205
1370
  this.state.elements.people_perms_list = this.peoplePermList(), // This gets replaced when actions taken
1206
1371
  // and form to add (dropdown of people and permissions)
@@ -1303,7 +1468,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1303
1468
  projectsRestContent() {
1304
1469
  return el('div', {}, [
1305
1470
  el('section', {}, [
1306
- el('h3', {textContent: "Projects"}),
1471
+ this.refreshableHeading("Projects", this.getProjectsList),
1307
1472
  // List of existing projects for this organization,
1308
1473
  this.state.elements.projects_display_list = this.projectsDisplayList(),
1309
1474
  // and, once expanded, a form to add a new project (id and name)
@@ -1348,14 +1513,16 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1348
1513
  // Content of the Nodes tab below the organization dropdown - only rendered once an org is selected
1349
1514
  nodesRestContent() {
1350
1515
  return el('div', {}, [
1351
- el('h3', {textContent: "Nodes in Organization"}),
1516
+ this.refreshableHeading("Nodes in Organization", this.refreshNodesTable),
1352
1517
  this.state.elements.nodes_table = this.nodesTable(),
1353
1518
  el('section', {}, [
1354
- el('h3', {textContent: "Enrolled nodes"}),
1519
+ this.refreshableHeading("Enrolled nodes", this.getEnrolledNodes),
1355
1520
  el('p', {textContent:
1356
1521
  "Each node has its own broker credential, which it collects from this server the first " +
1357
1522
  "time it connects. Forget one to have it issued a new credential - needed if its " +
1358
- "filesystem has been erased, because it can then no longer prove which node it is."}),
1523
+ "filesystem has been erased, because it can then no longer prove which node it is. A " +
1524
+ "node that asked and was refused is listed here as well, with why: click Approve on it " +
1525
+ "to accept its next request whatever secret it presents. It retries every half hour."}),
1359
1526
  this.state.elements.enrolled_nodes = this.enrolledNodesList(),
1360
1527
  ]),
1361
1528
  el('section', {}, [
@@ -1373,25 +1540,25 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1373
1540
  apiRestContent() {
1374
1541
  return el('div', {}, [
1375
1542
  el('section', {}, [
1376
- el('h3', {textContent: "Registered Platforms"}),
1543
+ this.refreshableHeading("Registered Platforms", this.getPlatformsList),
1377
1544
  this.state.elements.platforms_list_display = this.platformsListDisplay(),
1378
1545
  ]),
1379
1546
  el('section', {}, [
1380
1547
  this.state.elements.add_platform = this.collapsibleArea('add_platform', "Add Platform", this.platformRegisterForm),
1381
1548
  ]),
1382
1549
  el('section', {}, [
1383
- el('h3', {textContent: "Farms"}),
1550
+ this.refreshableHeading("Farms", this.getFarmsList),
1384
1551
  this.state.elements.farms_list_display = this.farmsListDisplay(),
1385
1552
  ]),
1386
1553
  el('section', {}, [
1387
1554
  this.state.elements.add_farm = this.collapsibleArea('add_farm', "Add Farm", this.farmRegisterForm),
1388
1555
  ]),
1389
1556
  el('section', {}, [
1390
- el('h3', {textContent: "Nodes in Farm"}),
1557
+ this.refreshableHeading("Nodes in Farm", this.getFarmNodesList),
1391
1558
  this.state.elements.farm_nodes_table = this.farmNodesTable(),
1392
1559
  ]),
1393
1560
  el('section', {}, [
1394
- el('h3', {textContent: "Node Actions"}),
1561
+ this.refreshableHeading("Node Actions", this.getDeviceActionSchema),
1395
1562
  this.state.elements.farm_node_actions = this.farmNodeActions(),
1396
1563
  this.state.elements.action_section = this.actionSection(),
1397
1564
  ]),
@@ -1407,6 +1574,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1407
1574
  { key: 'message', title: "Message", orgs: 'adminOrgs', dropdown: 'messageorgsdropdown', rest: 'message_rest', content: this.messageRestContent },
1408
1575
  { key: 'retained', title: "Retained", orgs: 'adminOrgs', dropdown: 'retainedorgsdropdown', rest: 'retained_rest', content: this.retainedRestContent },
1409
1576
  { key: 'nodes', title: "Nodes", orgs: 'adminOrgs', dropdown: 'nodesorgsdropdown', rest: 'nodes_rest', content: this.nodesRestContent },
1577
+ { key: 'bridges', title: "Bridges", orgs: 'adminOrgs', dropdown: 'bridgesorgsdropdown', rest: 'bridges_rest', content: this.bridgesRestContent },
1410
1578
  { key: 'api', title: "API", orgs: 'adminOrgs', dropdown: 'apiorgsdropdown', rest: 'api_rest', content: this.apiRestContent },
1411
1579
  ];
1412
1580
  }
package/core.js CHANGED
@@ -118,8 +118,14 @@ function unshiftUnique(arr, v) {
118
118
  }
119
119
  */
120
120
 
121
- // Subscribe to a topic (no wild cards as topic not passed to cb)
122
- function mqtt_subscribe(topic, cb) { // cb(message)
121
+ /*
122
+ * Subscribe to a topic. cb(topic, message, retained) - see mqtt_deliver, which passes all three.
123
+ *
124
+ * A trailing "/#" works. A "+" anywhere does NOT: topicMatches understands only the trailing form,
125
+ * so the broker would deliver the messages and none of them would reach cb. Subscribe to each
126
+ * concrete topic instead, or teach topicMatches about "+".
127
+ */
128
+ function mqtt_subscribe(topic, cb) { // cb(topic, message, retained)
123
129
  console.log("Subscribing to ", topic);
124
130
  mqtt_subscriptions.push({topic, cb});
125
131
  // There may be no client yet - it only connects once it knows which organization's credentials to use
@@ -159,13 +165,29 @@ function retainedPattern(org, relative) {
159
165
  return `${org}/${rest || '#'}`;
160
166
  }
161
167
 
168
+ /*
169
+ * The broker's host name, out of the URL a browser connects with.
170
+ *
171
+ * They are not the same thing and a node needs the host. A browser is given something like
172
+ * "wss://frugaliot.naturalinnovation.org/wss" - a WebSocket URL, through the reverse proxy - while
173
+ * firmware opens a plain TCP connection to the host on 1883 and resolves that string as a DNS name.
174
+ * Handing the URL to configure_mqtt_enrolled() gets "DNS Failed ... error -54" and a node that
175
+ * enrols and then never connects, which is how this was found.
176
+ */
177
+ function brokerHost(url) {
178
+ return String(url || '')
179
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // scheme
180
+ .replace(/\/.*$/, '') // path
181
+ .replace(/:\d+$/, ''); // port
182
+ }
183
+
162
184
  // Route a received message to every matching subscription.
163
185
  // Separate from the client's on('message') so a test or mock can inject messages with no broker.
164
- function mqtt_deliver(topic, msg) {
186
+ function mqtt_deliver(topic, msg, retained) {
165
187
  // The subscriptions are all going to be MqttNode which will then look at rest of topic
166
188
  for (let o of mqtt_subscriptions) {
167
189
  if (topicMatches(o.topic, topic)) { // Matches trailing wildcards, but not middle ones
168
- o.cb(topic, msg);
190
+ o.cb(topic, msg, retained);
169
191
  }
170
192
  }
171
193
  }
@@ -352,6 +374,7 @@ EN:
352
374
  Advanced: Advanced
353
375
  All: All
354
376
  Already have an account?: Already have an account?
377
+ "And these two, if you are not building against the production server:": "And these two, if you are not building against the production server:"
355
378
  API: API
356
379
  Approved: Approved
357
380
  AQI: AQI
@@ -524,12 +547,14 @@ EN:
524
547
  Provision instead (erases config): Provision instead (erases config)
525
548
  Publish Message: Publish Message
526
549
  "Published to ": "Published to "
550
+ "Put this in platformio-secrets-local.ini, which is not committed:": "Put this in platformio-secrets-local.ini, which is not committed:"
527
551
  QoS: QoS
528
552
  reconnect: reconnect
529
553
  Reconnect the board to flash again: Reconnect the board to flash again
530
554
  Register: Register
531
555
  Register a platform above before adding a farm.: Register a platform above before adding a farm.
532
556
  Register Farm: Register Farm
557
+ Refresh: Refresh
533
558
  Register Platform: Register Platform
534
559
  Registered Platforms: Registered Platforms
535
560
  Registration failed: Registration failed
@@ -624,6 +649,7 @@ FR:
624
649
  Advanced: Avancé
625
650
  All: Tous
626
651
  Already have an account?: Vous avez déjà un compte ?
652
+ "And these two, if you are not building against the production server:": "Et ces deux-ci, si vous ne compilez pas pour le serveur de production :"
627
653
  API: API
628
654
  Approved: Approuvé
629
655
  AQI: IQA
@@ -798,12 +824,14 @@ FR:
798
824
  Provision instead (erases config): Provisionner plutôt (effacer la configuration)
799
825
  Publish Message: Publier un message
800
826
  "Published to ": "Publié sur "
827
+ "Put this in platformio-secrets-local.ini, which is not committed:": "Mettez ceci dans platformio-secrets-local.ini, qui n'est pas commité :"
801
828
  QoS: QoS
802
829
  reconnect: reconnecter
803
830
  Reconnect the board to flash again: Reconnectez la carte pour flasher à nouveau
804
831
  Register: Registre
805
832
  Register a platform above before adding a farm.: Enregistrez une plateforme ci-dessus avant d'ajouter une ferme.
806
833
  Register Farm: Enregistrer la ferme
834
+ Refresh: Actualiser
807
835
  Register Platform: Enregistrer la plateforme
808
836
  Registered Platforms: Plateformes enregistrées
809
837
  Registration failed: L'inscription a échoué
@@ -898,6 +926,7 @@ HI:
898
926
  Advanced: उन्नत
899
927
  All: सभी
900
928
  Already have an account?: पहले से खाता है?
929
+ "And these two, if you are not building against the production server:": "और ये दो, यदि आप उत्पादन सर्वर के लिए बिल्ड नहीं कर रहे हैं:"
901
930
  API: एपीआई
902
931
  Approved: स्वीकृत
903
932
  AQI: वायु गुणवत्ता सूचकांक
@@ -1072,12 +1101,14 @@ HI:
1072
1101
  Provision instead (erases config): इसके बजाय प्रोविजन करें (कॉन्फ़िगरेशन मिटेगी)
1073
1102
  Publish Message: संदेश प्रकाशित करें
1074
1103
  "Published to ": "पर प्रकाशित किया गया "
1104
+ "Put this in platformio-secrets-local.ini, which is not committed:": "इसे platformio-secrets-local.ini में रखें, जो कमिट नहीं किया जाता:"
1075
1105
  QoS: QoS
1076
1106
  reconnect: पुनः कनेक्ट करें
1077
1107
  Reconnect the board to flash again: फिर से फ्लैश करने के लिए बोर्ड को दोबारा कनेक्ट करें
1078
1108
  Register: पंजीकरण करें
1079
1109
  Register a platform above before adding a farm.: फार्म जोड़ने से पहले ऊपर एक प्लेटफ़ॉर्म पंजीकृत करें।
1080
1110
  Register Farm: फार्म पंजीकृत करें
1111
+ Refresh: ताज़ा करें
1081
1112
  Register Platform: प्लेटफ़ॉर्म पंजीकृत करें
1082
1113
  Registered Platforms: पंजीकृत प्लेटफ़ॉर्म
1083
1114
  Registration failed: पंजीकरण विफल
@@ -1172,6 +1203,7 @@ ID:
1172
1203
  Advanced: Lanjutan
1173
1204
  All: Semua
1174
1205
  Already have an account?: Sudah punya akun?
1206
+ "And these two, if you are not building against the production server:": "Dan dua ini, jika Anda tidak membangun untuk server produksi:"
1175
1207
  API: API
1176
1208
  Approved: Disetujui
1177
1209
  AQI: Indeks Kualitas Udara
@@ -1346,12 +1378,14 @@ ID:
1346
1378
  Provision instead (erases config): Provisioning saja (menghapus konfigurasi)
1347
1379
  Publish Message: Publikasikan Pesan
1348
1380
  "Published to ": "Dipublikasikan ke "
1381
+ "Put this in platformio-secrets-local.ini, which is not committed:": "Letakkan ini di platformio-secrets-local.ini, yang tidak di-commit:"
1349
1382
  QoS: QoS
1350
1383
  reconnect: sambungkan kembali
1351
1384
  Reconnect the board to flash again: Hubungkan kembali papan untuk flash lagi
1352
1385
  Register: Daftar
1353
1386
  Register a platform above before adding a farm.: Daftarkan platform di atas sebelum menambahkan farm.
1354
1387
  Register Farm: Daftarkan Farm
1388
+ Refresh: Segarkan
1355
1389
  Register Platform: Daftarkan Platform
1356
1390
  Registered Platforms: Platform Terdaftar
1357
1391
  Registration failed: Pendaftaran gagal
@@ -2028,7 +2062,7 @@ class MqttTopic {
2028
2062
  display: this.type !== 'bool',
2029
2063
  title: {
2030
2064
  // noinspection JSUnresolvedReference
2031
- color: this.color, // May need to vary so not all e.g. humidity same color
2065
+ color: this.color, // The family colour; the per-line variation happens in MqttGraph.addDataset
2032
2066
  // noinspection JSUnresolvedReference
2033
2067
  text: getString(this.name.replace(/[0-9]+$/,'')),
2034
2068
  },
@@ -2064,7 +2098,6 @@ class MqttTopic {
2064
2098
 
2065
2099
  // Create a graphdataset to put in the chart
2066
2100
  if (!this.graphdataset) {
2067
- let nodename = this.node ? this.node.usableName : "";
2068
2101
  // noinspection JSUnresolvedReference
2069
2102
  this.graphdataset = el('mqtt-graphdataset', {
2070
2103
  // noinspection JSUnresolvedReference
@@ -2076,7 +2109,9 @@ class MqttTopic {
2076
2109
  min: this.min,
2077
2110
  max: this.max,
2078
2111
  yaxisid: yaxisid,
2079
- label: `${nodename}:${this.name}`
2112
+ // fullName, not name - the legend has to say which device a reading came from, and it goes
2113
+ // through nodeMt because this.node (the MqttNode element) is absent on a headless page.
2114
+ label: this.fullName,
2080
2115
  });
2081
2116
  this.graphdataset.mt = this;
2082
2117
  }
@@ -2367,9 +2402,18 @@ class MqttTopicProject extends MqttTopic {
2367
2402
  // Always created by MqttTopicProject.addNode. In normal mode MqttProject.addNode then adds the paired
2368
2403
  // MqttNode element (this.element) and replaces _groups with elNode.topicGroups.
2369
2404
  class MqttTopicNode extends MqttTopic {
2370
- // Route directly on the data-tree object rather than through the element.
2371
- message_received(topicPath, message) {
2372
- this.noteMessage();
2405
+ /*
2406
+ * Route directly on the data-tree object rather than through the element.
2407
+ *
2408
+ * A RETAINED delivery updates the values but does not count as having heard from the node. Nodes
2409
+ * publish almost everything retained, so subscribing produces a flood of last-known values
2410
+ * whatever state the node is in - and treating those as "just seen" made a device that died
2411
+ * weeks ago read as live, on any server whose logger had restarted since. The honest answer for
2412
+ * such a node is that we do not know when it last spoke, which is what an unset lastMessageAt
2413
+ * already says ("never"); config.json's lastseen fills it in where the logger does know.
2414
+ */
2415
+ message_received(topicPath, message, retained) {
2416
+ if (!retained) this.noteMessage();
2373
2417
  this.topicValueSet(topicPath, message);
2374
2418
  if (this.graphdataset) this.graphdataset.dataChanged();
2375
2419
  }
@@ -2803,12 +2847,16 @@ class MqttClient extends HTMLElementExtended {
2803
2847
  this.setStatus("Error:" + error.message);
2804
2848
  }.bind(this));
2805
2849
  // Message received, iterate over mqtt_subscriptions and call cb of subscription if matches
2806
- mqtt_client.on('message', (topic, message) => {
2850
+ mqtt_client.on('message', (topic, message, packet) => {
2807
2851
  // message is Buffer
2808
2852
  // TODO - check whether topic is string or buffer.
2809
2853
  let msg = message.toString();
2810
2854
  console.log("Received", topic, " ", msg);
2811
- mqtt_deliver(topic, msg);
2855
+ // packet.retain distinguishes the broker replaying its retained store, on subscribe, from
2856
+ // the node actually publishing. MQTT 3.1.1 requires the flag be cleared on a delivery to an
2857
+ // already-established subscription, so it is exactly "this arrived because we just
2858
+ // subscribed" - see MqttTopicNode.message_received for why that matters.
2859
+ mqtt_deliver(topic, msg, !!(packet && packet.retain));
2812
2860
  });
2813
2861
  } else {
2814
2862
  // console.log("XXX already started connection") // We expect this, probably one time
@@ -3131,6 +3179,7 @@ export {
3131
3179
  hasCapability,
3132
3180
  redirectToLogin,
3133
3181
  relativeTime,
3182
+ brokerHost,
3134
3183
  retainedPattern,
3135
3184
  SUMMARY_CHIP_LIMIT,
3136
3185
  SUMMARY_READINGS_PER_MODULE,
package/frugaliot.css CHANGED
@@ -200,6 +200,8 @@ div.mqtt-text {
200
200
  text-decoration: underline;
201
201
  cursor: pointer;
202
202
  }
203
+ /* The \27f3 beside a heading: still a link, but underlining a glyph reads as a typo. */
204
+ .refresh { text-decoration: none; }
203
205
  .message {margin: 0.5em; font-weight: bold; font-size: x-large; }
204
206
  /* An enrolment secret is a long unbroken string; without this it runs off a phone. */
205
207
  .enrolment__line { word-break: break-all; }
package/graph.js CHANGED
@@ -273,9 +273,21 @@ class MqttGraph extends MqttElement {
273
273
  this.chart.update();
274
274
  }
275
275
  addDataset(chartdataset) {
276
+ this.assignColor(chartdataset);
276
277
  this.datasets.push(chartdataset);
277
278
  this.makeChart();
278
279
  }
280
+ // Only the graph knows what is already drawn, so it is the graph that decides how far a line has
281
+ // to move off its schema colour. Lowest free variant, so removing a line frees its colour again.
282
+ assignColor(chartdataset) {
283
+ const base = chartdataset.baseColor;
284
+ const used = this.datasets.filter(d => d.baseColor === base).map(d => d.colorVariant);
285
+ let n = 0;
286
+ while (used.includes(n)) n++;
287
+ chartdataset.colorVariant = n;
288
+ chartdataset.borderColor = variantColor(base, n); // also sets colour of point
289
+ chartdataset.backgroundColor = addAlpha(chartdataset.borderColor, 0.3); // 30% opacity
290
+ }
279
291
  removeDataset(chartdataset) {
280
292
  // Removes a single chartjs dataset object from this graph and redraws.
281
293
  const idx = this.datasets.indexOf(chartdataset);
@@ -302,8 +314,75 @@ class MqttGraph extends MqttElement {
302
314
  }
303
315
  }
304
316
  customElements.define('mqtt-graph', MqttGraph);
305
- let lightenablecolors = ['coral','salmon','pink','salmon','yellow','goldenrodyellow',
306
- 'green','seagreen','cyan','steelblue','blue','skyblue','gray','slategray'];
317
+
318
+ /* ===== Colour helpers ==========
319
+ The schema gives one colour per kind of reading, so two humidities on the same graph arrive the
320
+ same blue. variantColor spreads them apart while keeping them recognisably the same family. */
321
+
322
+ // Only the names likely to appear in a schema; anything else falls through and is used unchanged.
323
+ const namedColors = {
324
+ black: [0, 0, 0], gray: [128, 128, 128], grey: [128, 128, 128], white: [255, 255, 255],
325
+ red: [255, 0, 0], green: [0, 128, 0], blue: [0, 0, 255], purple: [128, 0, 128],
326
+ brown: [165, 42, 42], orange: [255, 165, 0], yellow: [255, 255, 0], pink: [255, 192, 203],
327
+ cyan: [0, 255, 255], magenta: [255, 0, 255],
328
+ };
329
+
330
+ function colorToRgb(color) {
331
+ if (!color) return null;
332
+ const c = color.toLowerCase().trim();
333
+ if (c.startsWith('#')) {
334
+ const hex = c.length === 4 ? c[1] + c[1] + c[2] + c[2] + c[3] + c[3] : c.slice(1, 7);
335
+ if (hex.length !== 6) return null;
336
+ return [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16));
337
+ }
338
+ if (c.startsWith('rgb')) {
339
+ const parts = c.match(/[\d.]+/g);
340
+ return parts && parts.length >= 3 ? parts.slice(0, 3).map(Number) : null;
341
+ }
342
+ return namedColors[c] || null;
343
+ }
344
+
345
+ function addAlpha(color, alpha) {
346
+ if (!color) return color;
347
+ if (color.startsWith('rgba')) return color.replace(/[\d.]+\)$/, alpha + ')');
348
+ const rgb = colorToRgb(color);
349
+ return rgb ? `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})` : color;
350
+ }
351
+
352
+ function rgbToHsl([r, g, b]) {
353
+ r /= 255; g /= 255; b /= 255;
354
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2;
355
+ if (max === min) return [0, 0, l];
356
+ const d = max - min;
357
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
358
+ const h = (max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60;
359
+ return [h, s, l];
360
+ }
361
+
362
+ function hslToRgb([h, s, l]) {
363
+ const c = (1 - Math.abs(2 * l - 1)) * s;
364
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
365
+ const m = l - c / 2;
366
+ const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x]
367
+ : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
368
+ return [r, g, b].map(v => Math.round((v + m) * 255));
369
+ }
370
+
371
+ // n is how many lines already share this base colour: 0 keeps it, later ones step alternately either
372
+ // side in hue and lightness, so neighbours differ but all stay within a recognisable band.
373
+ function variantColor(baseColor, n) {
374
+ const rgb = n && colorToRgb(baseColor);
375
+ if (!rgb) return baseColor;
376
+ let [h, s, l] = rgbToHsl(rgb);
377
+ const step = (n % 2 ? 1 : -1) * Math.ceil(n / 2); // 1, -1, 2, -2 ...
378
+ if (s < 0.15) s = 0.45; // black and grey have no hue to rotate, so give the variants one
379
+ if (l < 0.2 || l > 0.8) l = 0.45; // and no room to move either, so start them mid-range
380
+ h = (h + step * 28 + 360) % 360;
381
+ l = Math.min(0.75, Math.max(0.25, l + step * 0.08));
382
+ const [vr, vg, vb] = hslToRgb([h, s, l]);
383
+ return `rgb(${vr}, ${vg}, ${vb})`;
384
+ }
385
+
307
386
  class MqttGraphDataset extends MqttElement {
308
387
  /*
309
388
  chartdataset: { data[{value, time}], parsing: { xAixKey: 'time', yAxisKey: 'value' }
@@ -349,50 +428,14 @@ class MqttGraphDataset extends MqttElement {
349
428
  };
350
429
  }
351
430
  // Things that are changed by attributes
352
- this.chartdataset.label = this.state.label; // TODO-80 Needs device name
353
- this.chartdataset.borderColor = this.state.color; // also sets color of point
354
- this.chartdataset.backgroundColor = this.addAlpha(this.state.color, 0.3); // 30% opacity
431
+ this.chartdataset.label = this.state.label;
432
+ // Only the schema colour is recorded here; MqttGraph.addDataset sets the line and fill colours,
433
+ // varying this one against whatever is already on the graph.
434
+ this.chartdataset.baseColor = this.state.color;
355
435
  this.chartdataset.yAxisID = this.state.yaxisid;
356
436
  // Should override display and position and grid of each axis used
357
437
  }
358
438
 
359
- // Helper method to add alpha transparency to a color
360
- addAlpha(color, alpha) {
361
- // Named color map for common colors
362
- const namedColors = {
363
- 'purple': 'rgb(128, 0, 128)',
364
- 'brown': 'rgb(165, 42, 42)',
365
- 'red': 'rgb(255, 0, 0)',
366
- 'pink': 'rgb(255, 192, 203)',
367
- 'green': 'rgb(0, 128, 0)',
368
- 'blue': 'rgb(0, 0, 255)',
369
- 'black': 'rgb(0, 0, 0)'
370
- };
371
-
372
- // If it's already an rgba color, modify the alpha
373
- if (color.startsWith('rgba')) {
374
- return color.replace(/[\d.]+\)$/, alpha + ')');
375
- }
376
- // Convert hex or named color to rgba
377
- if (color.startsWith('#')) {
378
- const r = parseInt(color.slice(1, 3), 16);
379
- const g = parseInt(color.slice(3, 5), 16);
380
- const b = parseInt(color.slice(5, 7), 16);
381
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
382
- }
383
- // If it's rgb, convert to rgba
384
- if (color.startsWith('rgb')) {
385
- return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
386
- }
387
- // Check for named colors
388
- const lowerColor = color.toLowerCase().trim();
389
- if (namedColors[lowerColor]) {
390
- return namedColors[lowerColor].replace('rgb', 'rgba').replace(')', `, ${alpha})`);
391
- }
392
- // For unknown colors, return as-is
393
- return color;
394
- }
395
-
396
439
  // Normally the MqttTopic creates the MqttGraphDataset,
397
440
  // However, in an embedded case, just the GraphDataset is created and has to create the topic.
398
441
  makeTopic() {
package/package.json CHANGED
@@ -29,7 +29,10 @@
29
29
  "license": "MIT",
30
30
  "main": "webcomponents.js",
31
31
  "name": "frugal-iot-client",
32
- "repository": "https://github.com/mitra42/frugal-iot-client",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/mitra42/frugal-iot-client.git"
35
+ },
33
36
  "scripts": {
34
37
  "fixtures": "node test/fixtures/regenerate.js",
35
38
  "link:hee": "ln -sfn ../../html-element-extended node_modules/html-element-extended && ls -ld node_modules/html-element-extended",
@@ -38,5 +41,5 @@
38
41
  "unlink:hee": "rm -f node_modules/html-element-extended && npm install html-element-extended"
39
42
  },
40
43
  "type": "module",
41
- "version": "2.0.0"
44
+ "version": "2.0.2"
42
45
  }
@@ -75,6 +75,27 @@ describe('formatting a value', () => {
75
75
  });
76
76
  });
77
77
 
78
+ describe('the broker host a node is told to use', () => {
79
+ // A node resolves this as a DNS name and opens a plain connection to 1883. The browser's own
80
+ // broker address is a WebSocket URL through the reverse proxy, and pasting that into a sketch
81
+ // produced a node that enrolled and then reported "DNS Failed ... -54" for ever.
82
+ test('the host comes out of the websocket URL a browser is given', () => {
83
+ assert.equal(core.brokerHost('wss://frugaliot.naturalinnovation.org/wss'),
84
+ 'frugaliot.naturalinnovation.org');
85
+ });
86
+
87
+ test('a port is not part of a host name', () => {
88
+ assert.equal(core.brokerHost('ws://frugaliot.local:9012'), 'frugaliot.local');
89
+ assert.equal(core.brokerHost('mqtt://192.168.1.107:1883'), '192.168.1.107');
90
+ });
91
+
92
+ test('a bare host is left alone, and nothing is not a crash', () => {
93
+ assert.equal(core.brokerHost('frugaliot.local'), 'frugaliot.local');
94
+ assert.equal(core.brokerHost(undefined), '');
95
+ assert.equal(core.brokerHost(null), '');
96
+ });
97
+ });
98
+
78
99
  describe('retained message patterns', () => {
79
100
  test('a pattern is written relative to the organization', () => {
80
101
  // "lotus/+/sht30" on org dev means dev/lotus/+/sht30 - nobody should have to type the org
@@ -16,6 +16,54 @@ before(async () => {
16
16
  mock.loadConfig(config);
17
17
  });
18
18
 
19
+ /*
20
+ * What counts as having heard from a node.
21
+ *
22
+ * Nodes publish almost everything retained, so subscribing replays every last-known value whatever
23
+ * state the node is in. Counting that as contact made a device that died weeks ago read as "live"
24
+ * on any server whose logger had restarted since - the logger only learns lastseen from the
25
+ * non-retained discovery heartbeat, so the nodes with no lastseen are exactly the dead ones.
26
+ */
27
+ describe('a retained message is the broker talking, not the node', () => {
28
+ const scenario = () => {
29
+ const { projectMt } = mock.runScenario('one-device');
30
+ return projectMt.nodes['esp8266-fb94bb'];
31
+ };
32
+
33
+ test('a replayed value does not make a silent node look live', () => {
34
+ const node = scenario();
35
+ node.lastMessageAt = undefined; // as it is before anything has been heard
36
+ core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '21.5', true);
37
+ assert.equal(node.age, null, 'age is unknown, not zero');
38
+ assert.equal(node.status, 'never');
39
+ });
40
+
41
+ test('but its value still arrives - the reading is current, the timestamp is not', () => {
42
+ const node = scenario();
43
+ node.lastMessageAt = undefined;
44
+ core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '23.5', true);
45
+ assert.equal(node.groups.sht.topics.temperature.state.value, 23.5);
46
+ });
47
+
48
+ test('a live publish does count', () => {
49
+ // The flag is clear on any delivery to an already-established subscription, so this is what a
50
+ // node actually reporting looks like
51
+ const node = scenario();
52
+ node.lastMessageAt = undefined;
53
+ core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '21.5', false);
54
+ assert.ok(node.age !== null && node.age < 1000);
55
+ assert.equal(node.status, 'live');
56
+ });
57
+
58
+ test('config.json lastseen is what fills the gap for a node we have not heard', () => {
59
+ // The wrapper seeds it from the server's own record, which comes from the discovery heartbeat
60
+ const node = scenario();
61
+ node.lastMessageAt = Date.now() - 60 * 60 * 1000;
62
+ core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '21.5', true);
63
+ assert.ok(node.age > 59 * 60 * 1000, 'a replay must not reset it to now');
64
+ });
65
+ });
66
+
19
67
  describe('discovery', () => {
20
68
  test('a node id on the project topic creates a node', () => {
21
69
  const { projectMt } = mock.runScenario('one-device');
@@ -246,3 +294,58 @@ describe('two temperature sources', () => {
246
294
  assert.equal(nodeMt.groups.ds18b20.state.name, 'Soil Temperature');
247
295
  });
248
296
  });
297
+
298
+ /*
299
+ * Seeding "last seen" from config.json.
300
+ *
301
+ * The server knows when it last saw each node - from the non-retained discovery heartbeat - and
302
+ * puts it in config.json. The wrapper uses that as its starting point, which is the only thing
303
+ * that tells a browser anything about a node that has not spoken since the page loaded. Load
304
+ * bearing since a retained delivery stopped counting as contact, and previously easy to lose: the
305
+ * seed is skipped if anything has already been heard, so a retained flood arriving first would
306
+ * silently replace an hour-old timestamp with "now".
307
+ *
308
+ * appender() is called directly rather than by appending the element: connectedCallback fetches
309
+ * /config.json unconditionally and there is no server here. Everything below it is the real path.
310
+ */
311
+ describe('last seen, taken from config.json', () => {
312
+ const AN_HOUR = 60 * 60 * 1000;
313
+
314
+ const wrapperFor = (isoSeen) => {
315
+ const cfg = JSON.parse(JSON.stringify(config));
316
+ cfg.organizations.dev.projects.lotus.nodes = { 'esp8266-fb94bb': { lastseen: isoSeen } };
317
+ core.configSet(cfg);
318
+ core.mqtt_unsubscribe_organization('dev'); // module-level, so it leaks between tests
319
+ const w = document.createElement('mqtt-wrapper');
320
+ w.setAttribute('organization', 'dev');
321
+ w.setAttribute('project', 'lotus');
322
+ w.appender();
323
+ return w;
324
+ };
325
+ const tidy = () => { core.mqtt_unsubscribe_organization('dev'); core.configSet(config); };
326
+
327
+ test('a node nobody has heard from still shows when the server last saw it', () => {
328
+ const w = wrapperFor(new Date(Date.now() - AN_HOUR).toISOString());
329
+ const node = w.projectMt && w.projectMt.nodes['esp8266-fb94bb'];
330
+ assert.ok(node, 'the node should exist from the config alone');
331
+ assert.ok(node.age >= AN_HOUR - 5000 && node.age <= AN_HOUR + 5000, `age was ${node.age}`);
332
+ assert.equal(node.status, 'offline'); // an hour with nothing heard, not "live"
333
+ tidy();
334
+ });
335
+
336
+ test('and a replayed retained value does not overwrite it', () => {
337
+ const w = wrapperFor(new Date(Date.now() - AN_HOUR).toISOString());
338
+ core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '21.5', true);
339
+ const node = w.projectMt.nodes['esp8266-fb94bb'];
340
+ assert.ok(node.age > AN_HOUR - 5000, `a replay reset it to ${node.age}`);
341
+ assert.equal(node.groups.sht.topics.temperature.state.value, 21.5, 'the value still arrives');
342
+ tidy();
343
+ });
344
+
345
+ test('a live publish does replace it, because that is real contact', () => {
346
+ const w = wrapperFor(new Date(Date.now() - AN_HOUR).toISOString());
347
+ core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '21.5', false);
348
+ assert.ok(w.projectMt.nodes['esp8266-fb94bb'].age < 5000);
349
+ tidy();
350
+ });
351
+ });
@@ -0,0 +1,69 @@
1
+ // What a graph line is called and what colour it is drawn in. Chart.js cannot draw in jsdom (no
2
+ // canvas), so these test the two decisions made before the chart is built.
3
+ import '../test/setup.js';
4
+ import { test, describe, before } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { readFileSync } from 'node:fs';
7
+
8
+ const config = JSON.parse(readFileSync(new URL('./fixtures/config.json', import.meta.url), 'utf8'));
9
+ let mock;
10
+
11
+ before(async () => {
12
+ mock = await import('./mock.js');
13
+ await import('../core.js');
14
+ await import('../graph.js');
15
+ mock.loadConfig(config);
16
+ });
17
+
18
+ describe('a legend entry names the device', () => {
19
+ test('the label a graphdataset is given says which node the reading came from', () => {
20
+ const { projectMt } = mock.runScenario('one-device');
21
+ const mt = projectMt.nodes['esp8266-fb94bb'].groups.sht.topics.temperature;
22
+ // The label createGraph passes; it went through node (the element), absent on the card UI
23
+ assert.equal(mt.fullName, 'Greenhouse North:SHT:Temperature');
24
+ });
25
+ });
26
+
27
+ describe('lines sharing a schema colour are told apart', () => {
28
+ const graph = () => new (customElements.get('mqtt-graph'))();
29
+ const added = (g, baseColor) => {
30
+ const ds = { baseColor };
31
+ g.assignColor(ds);
32
+ g.datasets.push(ds);
33
+ return ds;
34
+ };
35
+
36
+ test('the first line keeps its colour, later ones differ from it and each other', () => {
37
+ const g = graph();
38
+ const colors = [0, 1, 2].map(() => added(g, '#008000').borderColor);
39
+ assert.equal(colors[0], '#008000');
40
+ assert.equal(new Set(colors).size, 3);
41
+ });
42
+
43
+ test('a different base colour is unaffected by how many share the first', () => {
44
+ const g = graph();
45
+ added(g, '#008000');
46
+ added(g, '#008000');
47
+ assert.equal(added(g, '#0000ff').borderColor, '#0000ff');
48
+ });
49
+
50
+ test('removing a line frees its colour for the next one', () => {
51
+ const g = graph();
52
+ added(g, 'brown');
53
+ const second = added(g, 'brown');
54
+ g.datasets.splice(g.datasets.indexOf(second), 1);
55
+ assert.equal(added(g, 'brown').borderColor, second.borderColor);
56
+ });
57
+
58
+ test('a fill is the line colour at 30%', () => {
59
+ const g = graph();
60
+ assert.equal(added(g, '#008000').backgroundColor, 'rgba(0, 128, 0, 0.3)');
61
+ assert.match(added(g, '#008000').backgroundColor, /^rgba\(\d+, \d+, \d+, 0\.3\)$/);
62
+ });
63
+
64
+ test('black has no hue to rotate, so its variants still differ', () => {
65
+ const g = graph();
66
+ const colors = [0, 1, 2].map(() => added(g, '#000000').borderColor);
67
+ assert.equal(new Set(colors).size, 3);
68
+ });
69
+ });
package/test/page.test.js CHANGED
@@ -26,6 +26,50 @@ before(async () => {
26
26
  });
27
27
  beforeEach(() => { withCapabilities('READ'); try { localStorage.clear(); } catch (e) { /* none */ } });
28
28
 
29
+ // The permissions table outlives config.d - an organization dropped from it, or hosted on another
30
+ // server, keeps its rows, and buildConfigFor serves only the organizations the config has.
31
+ describe('a permission naming an organization that is not in the config', () => {
32
+ const withStaleOrg = () => mock.loadConfig({ ...base, user: { id: 2, name: 'test', permissions: [
33
+ { org: 'dev', capability: 'ADMIN' },
34
+ { org: 'dev', capability: 'OTAUPDATE' },
35
+ { org: 'gone', capability: 'ADMIN' }, // no such organization in this config
36
+ { org: 'gone', capability: 'OTAUPDATE' },
37
+ ] } });
38
+
39
+ test('does not stop the section rendering', () => {
40
+ withStaleOrg();
41
+ const admin = document.createElement('mqtt-admin');
42
+ admin.setAttribute('section', 'admin');
43
+ document.body.append(admin); // connectedCallback used to throw here
44
+ assert.ok(admin.shadowRoot.querySelector('.mqtt-admin'), 'the section rendered nothing');
45
+ admin.remove();
46
+ });
47
+
48
+ test('is left out, rather than offered and then failing on every card it opens', () => {
49
+ withStaleOrg();
50
+ const admin = document.createElement('mqtt-admin');
51
+ admin.setAttribute('section', 'admin');
52
+ document.body.append(admin);
53
+ assert.deepEqual(admin.adminOrgs, [['dev', 'Development']]);
54
+ assert.deepEqual(admin.otaOrgs, [['dev', 'Development']]);
55
+ // Only one left, so it is chosen without asking - which is the point of dropping the other
56
+ assert.equal(admin.state.org, 'dev');
57
+ admin.remove();
58
+ });
59
+
60
+ test('an organization-wide and a project-scoped row list it once', () => {
61
+ mock.loadConfig({ ...base, user: { id: 2, name: 'test', permissions: [
62
+ { org: 'dev', capability: 'ADMIN' },
63
+ { org: 'dev', capability: 'ADMIN', project: 'lotus' },
64
+ ] } });
65
+ const admin = document.createElement('mqtt-admin');
66
+ admin.setAttribute('section', 'admin');
67
+ document.body.append(admin);
68
+ assert.deepEqual(admin.adminOrgs, [['dev', 'Development']]);
69
+ admin.remove();
70
+ });
71
+ });
72
+
29
73
  describe('which admin cards a user gets', () => {
30
74
  test('a reader gets Info and nothing else', () => {
31
75
  // Info needs no capability: connection details are reference information, and having one card
@@ -143,8 +187,9 @@ describe('the project back', () => {
143
187
  // Reach into the shadow root: the card's own textContent does not see the section's headings
144
188
  const headings = (key) => {
145
189
  back.state.elements[key].querySelector('.fi-admincard__head').click();
190
+ // The heading's first span is its title - the one beside it is the refresh control
146
191
  return [...back.state.elements[key].querySelector('mqtt-admin')
147
- .shadowRoot.querySelectorAll('h3')].map((h) => h.textContent);
192
+ .shadowRoot.querySelectorAll('h3')].map((h) => (h.querySelector('span') || h).textContent);
148
193
  };
149
194
  assert.deepEqual(headings('admin'), ['Permissions'], 'people only');
150
195
  assert.deepEqual(headings('projects'), ['Projects']);