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 +4 -0
- package/admin.js +74 -25
- package/core.js +53 -10
- package/frugaliot.css +2 -0
- package/graph.js +85 -42
- package/package.json +1 -1
- package/test/cards.test.js +21 -0
- package/test/datatree.test.js +103 -0
- package/test/graph.test.js +69 -0
- package/test/page.test.js +46 -1
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
|
-
|
|
43
|
+
const orgs = new Map();
|
|
44
|
+
server_config.user.permissions
|
|
41
45
|
.filter(o => o.capability === capability)
|
|
42
|
-
.
|
|
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(
|
|
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 ?
|
|
242
|
-
el('
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 = (
|
|
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 = (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1400
|
+
this.refreshableHeading("Nodes in Organization", this.refreshNodesTable),
|
|
1352
1401
|
this.state.elements.nodes_table = this.nodesTable(),
|
|
1353
1402
|
el('section', {}, [
|
|
1354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, //
|
|
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
|
-
|
|
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
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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;
|
|
353
|
-
|
|
354
|
-
|
|
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
package/test/cards.test.js
CHANGED
|
@@ -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
|
package/test/datatree.test.js
CHANGED
|
@@ -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']);
|