frugal-iot-client 2.0.0 → 2.0.1

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, 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() {
@@ -911,6 +942,24 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
911
942
  }
912
943
  return this.state.elements[name];
913
944
  }
945
+ // Each of these lists is the result of one fetch made when the card opened - nothing pushes a
946
+ // change, so the heading carries the way to ask again.
947
+ refreshableHeading(title, refreshFn) {
948
+ return el('h3', {}, [
949
+ el('span', {textContent: title}),
950
+ el('span', {class: 'pseudolink refresh', i8n: false, title: getString("Refresh"),
951
+ textContent: ' \u27f3', onclick: refreshFn.bind(this)}),
952
+ ]);
953
+ }
954
+ // The organization's nodes are read out of server_config, so refreshing this table means fetching
955
+ // /config.json again rather than re-rendering what is already here.
956
+ refreshNodesTable() {
957
+ GET("/config.json", {}, (err, json) => {
958
+ if (err) { this.message(err.message); return; }
959
+ configSet(json);
960
+ this.replaceElement("nodes_table", this.nodesTable());
961
+ });
962
+ }
914
963
  setOrganization(org) {
915
964
  this.state.org = org;
916
965
  this.state.selected_platform_id = null;
@@ -1004,12 +1053,12 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1004
1053
 
1005
1054
  // Flattens server_config's nodes-by-project data for one org into the row shape nodesTableFor() renders.
1006
1055
  nodesForOrg(org) {
1007
- const projects = (server_config.organizations[org] || {}).projects || {};
1056
+ const projects = this.projectsOf(org);
1008
1057
  return this.nodesForProjects(org, Object.keys(projects));
1009
1058
  }
1010
1059
  // Same, but restricted to a specific set of project ids (e.g. those api_farms maps to a farm).
1011
1060
  nodesForProjects(org, projectIds) {
1012
- const projects = (server_config.organizations[org] || {}).projects || {};
1061
+ const projects = this.projectsOf(org);
1013
1062
  let nodes = [];
1014
1063
  projectIds.forEach(projectId => {
1015
1064
  const project = projects[projectId];
@@ -1109,7 +1158,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1109
1158
  // Create dropdown for selecting a different project for a node
1110
1159
  if (!org) { return el('span', {textContent: "No organization"}); }
1111
1160
  return el('select', {name: 'project'}, [
1112
- Object.entries(server_config.organizations[org].projects)
1161
+ Object.entries(this.projectsOf(org))
1113
1162
  .map(([pid, p]) => [ pid, p.name ])
1114
1163
  .map(([pid, name]) =>
1115
1164
  el('option', {value: pid, textContent: `${pid}: ${name}`, selected: pid === currentProjectId}))
@@ -1188,7 +1237,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1188
1237
  el('button', {class: "submit", type: "submit", textContent: 'Upload'}),
1189
1238
  ]), //form
1190
1239
  el('section', {}, [
1191
- el('h3', {textContent: "Existing OTA Files"}),
1240
+ this.refreshableHeading("Existing OTA Files", this.getOtaFiles),
1192
1241
  this.state.elements.ota_files = this.otaFilesList(),
1193
1242
  ]), // section ota
1194
1243
  el('section', {}, [
@@ -1200,7 +1249,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1200
1249
  adminRestContent() {
1201
1250
  return el('div', {}, [
1202
1251
  el('section', {}, [
1203
- el('h3', {textContent: "Permissions"}),
1252
+ this.refreshableHeading("Permissions", this.getPeopleList),
1204
1253
  // List of people and their permissions, with option to delete,
1205
1254
  this.state.elements.people_perms_list = this.peoplePermList(), // This gets replaced when actions taken
1206
1255
  // and form to add (dropdown of people and permissions)
@@ -1303,7 +1352,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1303
1352
  projectsRestContent() {
1304
1353
  return el('div', {}, [
1305
1354
  el('section', {}, [
1306
- el('h3', {textContent: "Projects"}),
1355
+ this.refreshableHeading("Projects", this.getProjectsList),
1307
1356
  // List of existing projects for this organization,
1308
1357
  this.state.elements.projects_display_list = this.projectsDisplayList(),
1309
1358
  // and, once expanded, a form to add a new project (id and name)
@@ -1348,10 +1397,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1348
1397
  // Content of the Nodes tab below the organization dropdown - only rendered once an org is selected
1349
1398
  nodesRestContent() {
1350
1399
  return el('div', {}, [
1351
- el('h3', {textContent: "Nodes in Organization"}),
1400
+ this.refreshableHeading("Nodes in Organization", this.refreshNodesTable),
1352
1401
  this.state.elements.nodes_table = this.nodesTable(),
1353
1402
  el('section', {}, [
1354
- el('h3', {textContent: "Enrolled nodes"}),
1403
+ this.refreshableHeading("Enrolled nodes", this.getEnrolledNodes),
1355
1404
  el('p', {textContent:
1356
1405
  "Each node has its own broker credential, which it collects from this server the first " +
1357
1406
  "time it connects. Forget one to have it issued a new credential - needed if its " +
@@ -1373,25 +1422,25 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1373
1422
  apiRestContent() {
1374
1423
  return el('div', {}, [
1375
1424
  el('section', {}, [
1376
- el('h3', {textContent: "Registered Platforms"}),
1425
+ this.refreshableHeading("Registered Platforms", this.getPlatformsList),
1377
1426
  this.state.elements.platforms_list_display = this.platformsListDisplay(),
1378
1427
  ]),
1379
1428
  el('section', {}, [
1380
1429
  this.state.elements.add_platform = this.collapsibleArea('add_platform', "Add Platform", this.platformRegisterForm),
1381
1430
  ]),
1382
1431
  el('section', {}, [
1383
- el('h3', {textContent: "Farms"}),
1432
+ this.refreshableHeading("Farms", this.getFarmsList),
1384
1433
  this.state.elements.farms_list_display = this.farmsListDisplay(),
1385
1434
  ]),
1386
1435
  el('section', {}, [
1387
1436
  this.state.elements.add_farm = this.collapsibleArea('add_farm', "Add Farm", this.farmRegisterForm),
1388
1437
  ]),
1389
1438
  el('section', {}, [
1390
- el('h3', {textContent: "Nodes in Farm"}),
1439
+ this.refreshableHeading("Nodes in Farm", this.getFarmNodesList),
1391
1440
  this.state.elements.farm_nodes_table = this.farmNodesTable(),
1392
1441
  ]),
1393
1442
  el('section', {}, [
1394
- el('h3', {textContent: "Node Actions"}),
1443
+ this.refreshableHeading("Node Actions", this.getDeviceActionSchema),
1395
1444
  this.state.elements.farm_node_actions = this.farmNodeActions(),
1396
1445
  this.state.elements.action_section = this.actionSection(),
1397
1446
  ]),
package/core.js CHANGED
@@ -159,13 +159,29 @@ function retainedPattern(org, relative) {
159
159
  return `${org}/${rest || '#'}`;
160
160
  }
161
161
 
162
+ /*
163
+ * The broker's host name, out of the URL a browser connects with.
164
+ *
165
+ * They are not the same thing and a node needs the host. A browser is given something like
166
+ * "wss://frugaliot.naturalinnovation.org/wss" - a WebSocket URL, through the reverse proxy - while
167
+ * firmware opens a plain TCP connection to the host on 1883 and resolves that string as a DNS name.
168
+ * Handing the URL to configure_mqtt_enrolled() gets "DNS Failed ... error -54" and a node that
169
+ * enrols and then never connects, which is how this was found.
170
+ */
171
+ function brokerHost(url) {
172
+ return String(url || '')
173
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // scheme
174
+ .replace(/\/.*$/, '') // path
175
+ .replace(/:\d+$/, ''); // port
176
+ }
177
+
162
178
  // Route a received message to every matching subscription.
163
179
  // Separate from the client's on('message') so a test or mock can inject messages with no broker.
164
- function mqtt_deliver(topic, msg) {
180
+ function mqtt_deliver(topic, msg, retained) {
165
181
  // The subscriptions are all going to be MqttNode which will then look at rest of topic
166
182
  for (let o of mqtt_subscriptions) {
167
183
  if (topicMatches(o.topic, topic)) { // Matches trailing wildcards, but not middle ones
168
- o.cb(topic, msg);
184
+ o.cb(topic, msg, retained);
169
185
  }
170
186
  }
171
187
  }
@@ -352,6 +368,7 @@ EN:
352
368
  Advanced: Advanced
353
369
  All: All
354
370
  Already have an account?: Already have an account?
371
+ "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
372
  API: API
356
373
  Approved: Approved
357
374
  AQI: AQI
@@ -524,12 +541,14 @@ EN:
524
541
  Provision instead (erases config): Provision instead (erases config)
525
542
  Publish Message: Publish Message
526
543
  "Published to ": "Published to "
544
+ "Put this in platformio-secrets-local.ini, which is not committed:": "Put this in platformio-secrets-local.ini, which is not committed:"
527
545
  QoS: QoS
528
546
  reconnect: reconnect
529
547
  Reconnect the board to flash again: Reconnect the board to flash again
530
548
  Register: Register
531
549
  Register a platform above before adding a farm.: Register a platform above before adding a farm.
532
550
  Register Farm: Register Farm
551
+ Refresh: Refresh
533
552
  Register Platform: Register Platform
534
553
  Registered Platforms: Registered Platforms
535
554
  Registration failed: Registration failed
@@ -624,6 +643,7 @@ FR:
624
643
  Advanced: Avancé
625
644
  All: Tous
626
645
  Already have an account?: Vous avez déjà un compte ?
646
+ "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
647
  API: API
628
648
  Approved: Approuvé
629
649
  AQI: IQA
@@ -798,12 +818,14 @@ FR:
798
818
  Provision instead (erases config): Provisionner plutôt (effacer la configuration)
799
819
  Publish Message: Publier un message
800
820
  "Published to ": "Publié sur "
821
+ "Put this in platformio-secrets-local.ini, which is not committed:": "Mettez ceci dans platformio-secrets-local.ini, qui n'est pas commité :"
801
822
  QoS: QoS
802
823
  reconnect: reconnecter
803
824
  Reconnect the board to flash again: Reconnectez la carte pour flasher à nouveau
804
825
  Register: Registre
805
826
  Register a platform above before adding a farm.: Enregistrez une plateforme ci-dessus avant d'ajouter une ferme.
806
827
  Register Farm: Enregistrer la ferme
828
+ Refresh: Actualiser
807
829
  Register Platform: Enregistrer la plateforme
808
830
  Registered Platforms: Plateformes enregistrées
809
831
  Registration failed: L'inscription a échoué
@@ -898,6 +920,7 @@ HI:
898
920
  Advanced: उन्नत
899
921
  All: सभी
900
922
  Already have an account?: पहले से खाता है?
923
+ "And these two, if you are not building against the production server:": "और ये दो, यदि आप उत्पादन सर्वर के लिए बिल्ड नहीं कर रहे हैं:"
901
924
  API: एपीआई
902
925
  Approved: स्वीकृत
903
926
  AQI: वायु गुणवत्ता सूचकांक
@@ -1072,12 +1095,14 @@ HI:
1072
1095
  Provision instead (erases config): इसके बजाय प्रोविजन करें (कॉन्फ़िगरेशन मिटेगी)
1073
1096
  Publish Message: संदेश प्रकाशित करें
1074
1097
  "Published to ": "पर प्रकाशित किया गया "
1098
+ "Put this in platformio-secrets-local.ini, which is not committed:": "इसे platformio-secrets-local.ini में रखें, जो कमिट नहीं किया जाता:"
1075
1099
  QoS: QoS
1076
1100
  reconnect: पुनः कनेक्ट करें
1077
1101
  Reconnect the board to flash again: फिर से फ्लैश करने के लिए बोर्ड को दोबारा कनेक्ट करें
1078
1102
  Register: पंजीकरण करें
1079
1103
  Register a platform above before adding a farm.: फार्म जोड़ने से पहले ऊपर एक प्लेटफ़ॉर्म पंजीकृत करें।
1080
1104
  Register Farm: फार्म पंजीकृत करें
1105
+ Refresh: ताज़ा करें
1081
1106
  Register Platform: प्लेटफ़ॉर्म पंजीकृत करें
1082
1107
  Registered Platforms: पंजीकृत प्लेटफ़ॉर्म
1083
1108
  Registration failed: पंजीकरण विफल
@@ -1172,6 +1197,7 @@ ID:
1172
1197
  Advanced: Lanjutan
1173
1198
  All: Semua
1174
1199
  Already have an account?: Sudah punya akun?
1200
+ "And these two, if you are not building against the production server:": "Dan dua ini, jika Anda tidak membangun untuk server produksi:"
1175
1201
  API: API
1176
1202
  Approved: Disetujui
1177
1203
  AQI: Indeks Kualitas Udara
@@ -1346,12 +1372,14 @@ ID:
1346
1372
  Provision instead (erases config): Provisioning saja (menghapus konfigurasi)
1347
1373
  Publish Message: Publikasikan Pesan
1348
1374
  "Published to ": "Dipublikasikan ke "
1375
+ "Put this in platformio-secrets-local.ini, which is not committed:": "Letakkan ini di platformio-secrets-local.ini, yang tidak di-commit:"
1349
1376
  QoS: QoS
1350
1377
  reconnect: sambungkan kembali
1351
1378
  Reconnect the board to flash again: Hubungkan kembali papan untuk flash lagi
1352
1379
  Register: Daftar
1353
1380
  Register a platform above before adding a farm.: Daftarkan platform di atas sebelum menambahkan farm.
1354
1381
  Register Farm: Daftarkan Farm
1382
+ Refresh: Segarkan
1355
1383
  Register Platform: Daftarkan Platform
1356
1384
  Registered Platforms: Platform Terdaftar
1357
1385
  Registration failed: Pendaftaran gagal
@@ -2028,7 +2056,7 @@ class MqttTopic {
2028
2056
  display: this.type !== 'bool',
2029
2057
  title: {
2030
2058
  // noinspection JSUnresolvedReference
2031
- color: this.color, // May need to vary so not all e.g. humidity same color
2059
+ color: this.color, // The family colour; the per-line variation happens in MqttGraph.addDataset
2032
2060
  // noinspection JSUnresolvedReference
2033
2061
  text: getString(this.name.replace(/[0-9]+$/,'')),
2034
2062
  },
@@ -2064,7 +2092,6 @@ class MqttTopic {
2064
2092
 
2065
2093
  // Create a graphdataset to put in the chart
2066
2094
  if (!this.graphdataset) {
2067
- let nodename = this.node ? this.node.usableName : "";
2068
2095
  // noinspection JSUnresolvedReference
2069
2096
  this.graphdataset = el('mqtt-graphdataset', {
2070
2097
  // noinspection JSUnresolvedReference
@@ -2076,7 +2103,9 @@ class MqttTopic {
2076
2103
  min: this.min,
2077
2104
  max: this.max,
2078
2105
  yaxisid: yaxisid,
2079
- label: `${nodename}:${this.name}`
2106
+ // fullName, not name - the legend has to say which device a reading came from, and it goes
2107
+ // through nodeMt because this.node (the MqttNode element) is absent on a headless page.
2108
+ label: this.fullName,
2080
2109
  });
2081
2110
  this.graphdataset.mt = this;
2082
2111
  }
@@ -2367,9 +2396,18 @@ class MqttTopicProject extends MqttTopic {
2367
2396
  // Always created by MqttTopicProject.addNode. In normal mode MqttProject.addNode then adds the paired
2368
2397
  // MqttNode element (this.element) and replaces _groups with elNode.topicGroups.
2369
2398
  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();
2399
+ /*
2400
+ * Route directly on the data-tree object rather than through the element.
2401
+ *
2402
+ * A RETAINED delivery updates the values but does not count as having heard from the node. Nodes
2403
+ * publish almost everything retained, so subscribing produces a flood of last-known values
2404
+ * whatever state the node is in - and treating those as "just seen" made a device that died
2405
+ * weeks ago read as live, on any server whose logger had restarted since. The honest answer for
2406
+ * such a node is that we do not know when it last spoke, which is what an unset lastMessageAt
2407
+ * already says ("never"); config.json's lastseen fills it in where the logger does know.
2408
+ */
2409
+ message_received(topicPath, message, retained) {
2410
+ if (!retained) this.noteMessage();
2373
2411
  this.topicValueSet(topicPath, message);
2374
2412
  if (this.graphdataset) this.graphdataset.dataChanged();
2375
2413
  }
@@ -2803,12 +2841,16 @@ class MqttClient extends HTMLElementExtended {
2803
2841
  this.setStatus("Error:" + error.message);
2804
2842
  }.bind(this));
2805
2843
  // Message received, iterate over mqtt_subscriptions and call cb of subscription if matches
2806
- mqtt_client.on('message', (topic, message) => {
2844
+ mqtt_client.on('message', (topic, message, packet) => {
2807
2845
  // message is Buffer
2808
2846
  // TODO - check whether topic is string or buffer.
2809
2847
  let msg = message.toString();
2810
2848
  console.log("Received", topic, " ", msg);
2811
- mqtt_deliver(topic, msg);
2849
+ // packet.retain distinguishes the broker replaying its retained store, on subscribe, from
2850
+ // the node actually publishing. MQTT 3.1.1 requires the flag be cleared on a delivery to an
2851
+ // already-established subscription, so it is exactly "this arrived because we just
2852
+ // subscribed" - see MqttTopicNode.message_received for why that matters.
2853
+ mqtt_deliver(topic, msg, !!(packet && packet.retain));
2812
2854
  });
2813
2855
  } else {
2814
2856
  // console.log("XXX already started connection") // We expect this, probably one time
@@ -3131,6 +3173,7 @@ export {
3131
3173
  hasCapability,
3132
3174
  redirectToLogin,
3133
3175
  relativeTime,
3176
+ brokerHost,
3134
3177
  retainedPattern,
3135
3178
  SUMMARY_CHIP_LIMIT,
3136
3179
  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
@@ -38,5 +38,5 @@
38
38
  "unlink:hee": "rm -f node_modules/html-element-extended && npm install html-element-extended"
39
39
  },
40
40
  "type": "module",
41
- "version": "2.0.0"
41
+ "version": "2.0.1"
42
42
  }
@@ -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']);