frugal-iot-client 2.0.1 → 2.0.3
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/admin.js +194 -10
- package/cards.js +1 -0
- package/core.js +33 -9
- package/package.json +5 -2
- package/test/graph.test.js +21 -0
- package/test/page.test.js +3 -3
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, brokerHost, configSet, el, getString, hasCapability, mqttTempConnect, retainedPattern, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
|
|
10
|
+
import { CssUrl, DELETE, POST, XXX, brokerHost, configSet, el, getString, hasCapability, mqttTempConnect, mqtt_subscribe, retainedPattern, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
// ---------- USB flashing over WebSerial ----------
|
|
@@ -291,6 +291,157 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
291
291
|
this.replaceElement("enrolled_nodes", this.enrolledNodesList());
|
|
292
292
|
});
|
|
293
293
|
}
|
|
294
|
+
/*
|
|
295
|
+
* The Pis bridging to this server, and whether each is currently connected.
|
|
296
|
+
*
|
|
297
|
+
* Two independent answers, which is the point of showing both. "Last check-in" is the server's
|
|
298
|
+
* own record of that Pi collecting replicated logins, so it says the Pi is alive and talking
|
|
299
|
+
* HTTP. "Bridge" is the broker's retained state for that account, so it says the MQTT relay
|
|
300
|
+
* itself is up. A Pi with a broken bridge still checks in, and that difference is exactly what
|
|
301
|
+
* is hard to see from production any other way.
|
|
302
|
+
*/
|
|
303
|
+
getBridges() {
|
|
304
|
+
if (!this.state.org) return;
|
|
305
|
+
GET(`/bridges_list/${this.state.org}`, {}, (err, json) => {
|
|
306
|
+
if (err) { this.message(err.message); return; }
|
|
307
|
+
this.state.bridges = json; // [{ site, account, created_at, last_pull }]
|
|
308
|
+
this.subscribeBridgeState();
|
|
309
|
+
this.replaceElement("bridges_list", this.bridgesList());
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/*
|
|
313
|
+
* Watch each bridge's connection state.
|
|
314
|
+
*
|
|
315
|
+
* One subscription per bridge rather than the "+" wildcard, because mqtt_deliver matches an
|
|
316
|
+
* incoming topic with topicMatches, which understands a trailing "/#" and nothing else - a "+" in
|
|
317
|
+
* the middle is compared literally, so the broker would send the messages and none would ever be
|
|
318
|
+
* delivered to this callback. Exact topics are narrower anyway, and the retained value arrives
|
|
319
|
+
* immediately, so a state appears without waiting for anything to change.
|
|
320
|
+
*
|
|
321
|
+
* Tracked in a Set because mqtt_subscribe does not de-duplicate: without this, every refresh and
|
|
322
|
+
* every organization change would add another copy of the same subscription.
|
|
323
|
+
*/
|
|
324
|
+
subscribeBridgeState() {
|
|
325
|
+
this.state.bridge_state = this.state.bridge_state || {};
|
|
326
|
+
this.state.bridge_subscribed = this.state.bridge_subscribed || new Set();
|
|
327
|
+
for (const b of (this.state.bridges || [])) {
|
|
328
|
+
const topic = `$SYS/broker/connection/${b.account}/state`;
|
|
329
|
+
if (this.state.bridge_subscribed.has(topic)) continue;
|
|
330
|
+
this.state.bridge_subscribed.add(topic);
|
|
331
|
+
// (topic, msg, retained) - the first argument is the topic, not the message
|
|
332
|
+
mqtt_subscribe(topic, (_topic, msg) => {
|
|
333
|
+
this.state.bridge_state[b.account] = String(msg);
|
|
334
|
+
this.replaceElement("bridges_list", this.bridgesList());
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
bridgesList() {
|
|
339
|
+
const bridges = this.state.bridges;
|
|
340
|
+
if (!bridges || !bridges.length) {
|
|
341
|
+
return el('p', {textContent:
|
|
342
|
+
"No Pi bridges to this server for this organization. A bridge relays a Pi's readings here " +
|
|
343
|
+
"while the Pi carries on working on its own - see INSTALLATION.md step 11."});
|
|
344
|
+
}
|
|
345
|
+
const state = this.state.bridge_state || {};
|
|
346
|
+
return el('table', {class: 'nodestates'}, [
|
|
347
|
+
el('tr', {}, [
|
|
348
|
+
el('th', {textContent: "Site"}), el('th', {textContent: "Bridge"}),
|
|
349
|
+
el('th', {textContent: "Last check-in"}), el('th', {textContent: "Added"}),
|
|
350
|
+
]),
|
|
351
|
+
...bridges.map((b) => {
|
|
352
|
+
// Three states, not two: nothing received yet is not the same as "down", and saying
|
|
353
|
+
// "Unknown" avoids reporting a healthy bridge as broken while the retained message is still
|
|
354
|
+
// in flight or when this login has not been granted the $SYS topic.
|
|
355
|
+
const raw = state[b.account];
|
|
356
|
+
const up = (raw === undefined) ? { label: "Unknown", hint: "No state received from the broker yet" }
|
|
357
|
+
: (raw === '1') ? { label: "Up", hint: "The broker reports this bridge connected" }
|
|
358
|
+
: { label: "DOWN", hint: "The broker reports this bridge not connected" };
|
|
359
|
+
return el('tr', {}, [
|
|
360
|
+
el('td', {i8n: false, textContent: b.site}),
|
|
361
|
+
el('td', {i8n: false, title: getString(up.hint), textContent: up.label}),
|
|
362
|
+
el('td', {i8n: false, textContent: b.last_pull ? this.formatLastSeen(new Date(b.last_pull).toISOString()) : getString("Never seen")}),
|
|
363
|
+
el('td', {i8n: false, textContent: b.created_at ? this.formatLastSeen(new Date(b.created_at).toISOString()) : ''}),
|
|
364
|
+
]);
|
|
365
|
+
}),
|
|
366
|
+
]);
|
|
367
|
+
}
|
|
368
|
+
/*
|
|
369
|
+
* Add a bridge, or re-issue an existing one's credentials.
|
|
370
|
+
*
|
|
371
|
+
* The result is shown once and is not stored anywhere it can be read back: the password is
|
|
372
|
+
* derived from a server secret and this row's timestamp, so the only copy is the one you put into
|
|
373
|
+
* the Pi's configuration. Re-running for the same site mints new ones and revokes the old, which
|
|
374
|
+
* is what makes it the way to recover from having lost them.
|
|
375
|
+
*/
|
|
376
|
+
onBridgeAdd() {
|
|
377
|
+
const input = this.state.elements.bridge_site;
|
|
378
|
+
const site = (input && input.value || '').trim();
|
|
379
|
+
if (!site) { this.message(getString("Give the Pi a short name first")); return; }
|
|
380
|
+
if (this.state.bridges && this.state.bridges.some((b) => b.site === site)
|
|
381
|
+
&& !window.confirm(
|
|
382
|
+
`"${site}" already exists.\n\n` +
|
|
383
|
+
`Adding it again issues a new broker password and a new replica token, and the ones that ` +
|
|
384
|
+
`Pi is using now stop working. It will stay disconnected until you put the new ones into ` +
|
|
385
|
+
`its configuration and restart its broker.`)) return;
|
|
386
|
+
POST(`/bridge_add/${this.state.org}`, { site }, (err, json) => {
|
|
387
|
+
if (err) { this.message(err.message); return; }
|
|
388
|
+
this.state.new_bridge = json; // shown once, below
|
|
389
|
+
this.getBridges(); // refreshes the table and re-renders the card
|
|
390
|
+
this.replaceElement("bridge_new", this.newBridgeDetails());
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/*
|
|
394
|
+
* What the Pi needs, once. Deliberately not fetchable again: there is no route that reads a
|
|
395
|
+
* bridge's password back, so losing it means issuing a new one.
|
|
396
|
+
*/
|
|
397
|
+
newBridgeDetails() {
|
|
398
|
+
const b = this.state.new_bridge;
|
|
399
|
+
if (!b) return el('span', {});
|
|
400
|
+
return el('div', {class: 'bridge-new'}, [
|
|
401
|
+
el('h4', {textContent: "Copy these now - they are not shown again"}),
|
|
402
|
+
el('p', {textContent:
|
|
403
|
+
"Run this on the Pi, in the directory the server is installed in. Replace the placeholder " +
|
|
404
|
+
"with this server's fully-qualified name - the one on its certificate, which you can check " +
|
|
405
|
+
"by opening https:// that name in a browser. It will then ask for the broker password and " +
|
|
406
|
+
"for the replica token, in that order, so have both of the values below to hand."}),
|
|
407
|
+
el('pre', {i8n: false, textContent: b.command}),
|
|
408
|
+
el('table', {class: 'nodestates'}, [
|
|
409
|
+
el('tr', {}, [el('th', {textContent: "Broker account"}), el('td', {i8n: false, textContent: b.account})]),
|
|
410
|
+
el('tr', {}, [el('th', {textContent: "Broker password"}), el('td', {i8n: false, textContent: b.password})]),
|
|
411
|
+
el('tr', {}, [el('th', {textContent: "Replica token"}), el('td', {i8n: false, textContent: b.replica_token})]),
|
|
412
|
+
]),
|
|
413
|
+
el('p', {textContent:
|
|
414
|
+
"Two credentials for two different connections: the password is what the MQTT bridge logs " +
|
|
415
|
+
"in with on port 8883, and the token is what lets that Pi pull this organization's logins " +
|
|
416
|
+
"over HTTPS, so the same people can log in there too. The token can be skipped - press " +
|
|
417
|
+
"Enter at its prompt - and added later by doing this again. Neither is asked for on the " +
|
|
418
|
+
"command line, so that neither ends up in the Pi's shell history."}),
|
|
419
|
+
]);
|
|
420
|
+
}
|
|
421
|
+
bridgesRestContent() {
|
|
422
|
+
return el('div', {}, [
|
|
423
|
+
this.refreshableHeading("Bridged Pis", this.getBridges),
|
|
424
|
+
el('p', {textContent:
|
|
425
|
+
"A bridge relays a Pi's readings to this server, so its nodes appear here too, while the " +
|
|
426
|
+
"Pi carries on recording and serving on its own whenever the link is down. \"Bridge\" is " +
|
|
427
|
+
"the broker's live view of the relay; \"Last check-in\" is when that Pi last collected " +
|
|
428
|
+
"this organization's logins, which it does over HTTP - so a Pi can be checking in while " +
|
|
429
|
+
"its bridge is down, and the two together say which half is broken."}),
|
|
430
|
+
this.state.elements.bridges_list = this.bridgesList(),
|
|
431
|
+
el('section', {}, [
|
|
432
|
+
el('h3', {textContent: "Add a bridge"}),
|
|
433
|
+
el('p', {textContent:
|
|
434
|
+
"Give the Pi a short name - it becomes its broker account and cannot be changed later " +
|
|
435
|
+
"without reconfiguring that Pi. Lower-case letters, digits and hyphens."}),
|
|
436
|
+
el('div', {}, [
|
|
437
|
+
this.state.elements.bridge_site = el('input',
|
|
438
|
+
{type: 'text', name: 'site', placeholder: 'pi4', size: 16}),
|
|
439
|
+
el('button', {textContent: "Add", onclick: this.onBridgeAdd.bind(this)}),
|
|
440
|
+
]),
|
|
441
|
+
this.state.elements.bridge_new = this.newBridgeDetails(),
|
|
442
|
+
]),
|
|
443
|
+
]);
|
|
444
|
+
}
|
|
294
445
|
onNodeReset(project, nodeid) {
|
|
295
446
|
// Destructive and not obvious: forgetting a node that is running is safe (it notices its
|
|
296
447
|
// credential being refused and enrols again) but it does cost that node a few minutes offline.
|
|
@@ -315,10 +466,32 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
315
466
|
*/
|
|
316
467
|
nodeStates() {
|
|
317
468
|
return {
|
|
318
|
-
enrolled: { label: "Yes",
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
469
|
+
enrolled: { label: "Yes", next: 'denied', hint: "Has its own broker credential. Click to deny it." },
|
|
470
|
+
// "Failed" alone read as a status rather than a control, so the one action this card exists to
|
|
471
|
+
// offer - approving a node that cannot prove anything - was not findable by someone looking
|
|
472
|
+
// for it. The label says what clicking does.
|
|
473
|
+
failed: { label: "Failed - approve", next: 'approved', hint: "Asked and was refused. Click to let it enrol." },
|
|
474
|
+
approved: { label: "Approved", next: 'denied', hint: "Its next request will be accepted. Click to deny it." },
|
|
475
|
+
denied: { label: "Denied", next: 'cleared', hint: "Stopped, and its credential removed. Click to clear." },
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/*
|
|
479
|
+
* Why the server turned a node away, in words rather than the wire code.
|
|
480
|
+
*
|
|
481
|
+
* Worth the table: the codes are deliberately vague to the NODE (so that probing an organization
|
|
482
|
+
* name learns nothing), but an administrator looking at their own organization needs the
|
|
483
|
+
* difference - "wrong secret" and "project not registered" have completely different fixes, and
|
|
484
|
+
* neither is fixed by approving the node.
|
|
485
|
+
*/
|
|
486
|
+
nodeAskReasons() {
|
|
487
|
+
return {
|
|
488
|
+
refused: "enrolment secret wrong or missing",
|
|
489
|
+
no_such_project: "project is not registered here",
|
|
490
|
+
bad_request: "malformed org, project or node id",
|
|
491
|
+
needs_reset: "already enrolled, cannot prove it",
|
|
492
|
+
denied: "denied here",
|
|
493
|
+
rate_limited: "asking too often",
|
|
494
|
+
broker_unavailable: "broker was unavailable",
|
|
322
495
|
};
|
|
323
496
|
}
|
|
324
497
|
onNodeState(project, nodeid, state) {
|
|
@@ -335,7 +508,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
335
508
|
const nodes = this.state.enrolled_nodes;
|
|
336
509
|
if (!nodes || !nodes.length) {
|
|
337
510
|
return el('p', {textContent:
|
|
338
|
-
"No nodes have enrolled yet. A node enrols itself the first time it
|
|
511
|
+
"No nodes have enrolled yet, and none is asking to. A node enrols itself the first time it " +
|
|
512
|
+
"connects; one that asks and is refused is listed here too, so an empty list means nothing " +
|
|
513
|
+
"is reaching this server at all - check that the node's org and enrol URL are this one."});
|
|
339
514
|
}
|
|
340
515
|
const states = this.nodeStates();
|
|
341
516
|
return el('table', {class: 'nodestates'}, [
|
|
@@ -353,10 +528,14 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
353
528
|
el('span', {class: 'pseudolink', title: getString(st.hint), textContent: st.label,
|
|
354
529
|
onclick: this.onNodeState.bind(this, n.project, n.nodeid, st.next)}),
|
|
355
530
|
]),
|
|
356
|
-
// Untrusted, and shown as such: an address and a time for the admin to recognise
|
|
531
|
+
// Untrusted, and shown as such: an address and a time for the admin to recognise. The
|
|
532
|
+
// reason is here too - without it every refusal looks the same, and the ones an admin can
|
|
533
|
+
// do something about (a project that is not registered, a node id that is not a node id)
|
|
534
|
+
// are indistinguishable from the one that approving fixes.
|
|
357
535
|
el('td', {i8n: false, textContent: n.asked_at
|
|
358
536
|
? `${this.formatLastSeen(new Date(n.asked_at).toISOString())} from ${n.asked_from || '?'}` +
|
|
359
|
-
`${n.asked_count > 1 ? ` (${n.asked_count}x)` : ''}`
|
|
537
|
+
`${n.asked_count > 1 ? ` (${n.asked_count}x)` : ''}` +
|
|
538
|
+
`${n.asked_reason ? ` - ${this.nodeAskReasons()[n.asked_reason] || n.asked_reason}` : ''}`
|
|
360
539
|
: (n.enrolled_at ? new Date(n.enrolled_at).toISOString().slice(0, 10) : '')}),
|
|
361
540
|
el('td', {}, [
|
|
362
541
|
n.enrolled_at
|
|
@@ -982,7 +1161,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
982
1161
|
// the enrolled-node list as well as reading server_config - leaving it out was why that section
|
|
983
1162
|
// always said "No nodes have enrolled yet": loadSectionIfNeeded returns early for a tab that is not
|
|
984
1163
|
// in this set, so the fetch never happened.
|
|
985
|
-
this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'API']);
|
|
1164
|
+
this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'Bridges', 'API']);
|
|
986
1165
|
this.loadSectionIfNeeded(this.state.activeSectionTitle);
|
|
987
1166
|
// Only a card created without an organization has a dropdown of its own; replaceElement does
|
|
988
1167
|
// nothing for an element this rendering never made. Fine too if this.state.org is undefined.
|
|
@@ -1020,6 +1199,8 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1020
1199
|
this.getProjectsList();
|
|
1021
1200
|
} else if (title === 'Nodes') {
|
|
1022
1201
|
this.getEnrolledNodes();
|
|
1202
|
+
} else if (title === 'Bridges') {
|
|
1203
|
+
this.getBridges();
|
|
1023
1204
|
} else if (title === 'API') {
|
|
1024
1205
|
this.getPlatformsList();
|
|
1025
1206
|
this.getFarmsList();
|
|
@@ -1404,7 +1585,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1404
1585
|
el('p', {textContent:
|
|
1405
1586
|
"Each node has its own broker credential, which it collects from this server the first " +
|
|
1406
1587
|
"time it connects. Forget one to have it issued a new credential - needed if its " +
|
|
1407
|
-
"filesystem has been erased, because it can then no longer prove which node it is."
|
|
1588
|
+
"filesystem has been erased, because it can then no longer prove which node it is. A " +
|
|
1589
|
+
"node that asked and was refused is listed here as well, with why: click Approve on it " +
|
|
1590
|
+
"to accept its next request whatever secret it presents. It retries every half hour."}),
|
|
1408
1591
|
this.state.elements.enrolled_nodes = this.enrolledNodesList(),
|
|
1409
1592
|
]),
|
|
1410
1593
|
el('section', {}, [
|
|
@@ -1456,6 +1639,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1456
1639
|
{ key: 'message', title: "Message", orgs: 'adminOrgs', dropdown: 'messageorgsdropdown', rest: 'message_rest', content: this.messageRestContent },
|
|
1457
1640
|
{ key: 'retained', title: "Retained", orgs: 'adminOrgs', dropdown: 'retainedorgsdropdown', rest: 'retained_rest', content: this.retainedRestContent },
|
|
1458
1641
|
{ key: 'nodes', title: "Nodes", orgs: 'adminOrgs', dropdown: 'nodesorgsdropdown', rest: 'nodes_rest', content: this.nodesRestContent },
|
|
1642
|
+
{ key: 'bridges', title: "Bridges", orgs: 'adminOrgs', dropdown: 'bridgesorgsdropdown', rest: 'bridges_rest', content: this.bridgesRestContent },
|
|
1459
1643
|
{ key: 'api', title: "API", orgs: 'adminOrgs', dropdown: 'apiorgsdropdown', rest: 'api_rest', content: this.apiRestContent },
|
|
1460
1644
|
];
|
|
1461
1645
|
}
|
package/cards.js
CHANGED
|
@@ -733,6 +733,7 @@ const ADMIN_CARDS = [
|
|
|
733
733
|
{ section: 'message', title: 'Publish Message', capability: 'ADMIN' },
|
|
734
734
|
{ section: 'retained', title: 'Retained messages', capability: 'ADMIN' },
|
|
735
735
|
{ section: 'nodes', title: 'Nodes', capability: 'ADMIN' },
|
|
736
|
+
{ section: 'bridges', title: 'Bridges', capability: 'ADMIN' },
|
|
736
737
|
{ section: 'api', title: 'API', capability: 'ADMIN' },
|
|
737
738
|
];
|
|
738
739
|
|
package/core.js
CHANGED
|
@@ -118,15 +118,38 @@ function unshiftUnique(arr, v) {
|
|
|
118
118
|
}
|
|
119
119
|
*/
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
/*
|
|
122
|
+
* Say what the broker made of a subscription.
|
|
123
|
+
*
|
|
124
|
+
* A broker that REFUSES one does not report an error: MQTT 3.1.1 answers with SUBACK return code
|
|
125
|
+
* 0x80, which mqtt.js hands back as granted qos 128 in the second argument. Checking only `err`
|
|
126
|
+
* therefore made a refusal completely silent - "Subscribing to X" and then nothing, for ever,
|
|
127
|
+
* looking exactly like a topic nobody publishes to. That is the difference between "no permission"
|
|
128
|
+
* and "no data", and it is the first thing you want to know.
|
|
129
|
+
*/
|
|
130
|
+
function subscribeResult(topic, err, granted) {
|
|
131
|
+
if (err) { console.error("Subscribe failed", topic, err); return; }
|
|
132
|
+
const refused = (granted || []).filter((g) => g.qos === 128);
|
|
133
|
+
if (refused.length) {
|
|
134
|
+
console.error(`Subscribe REFUSED by the broker: ${topic} - this login's broker account has no `
|
|
135
|
+
+ `permission for it. Its rights are set when it logs in; a permission added since then `
|
|
136
|
+
+ `needs a fresh login, or "frugal-iot-rebuild-dynsec" on the server.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/*
|
|
141
|
+
* Subscribe to a topic. cb(topic, message, retained) - see mqtt_deliver, which passes all three.
|
|
142
|
+
*
|
|
143
|
+
* A trailing "/#" works. A "+" anywhere does NOT: topicMatches understands only the trailing form,
|
|
144
|
+
* so the broker would deliver the messages and none of them would reach cb. Subscribe to each
|
|
145
|
+
* concrete topic instead, or teach topicMatches about "+".
|
|
146
|
+
*/
|
|
147
|
+
function mqtt_subscribe(topic, cb) { // cb(topic, message, retained)
|
|
123
148
|
console.log("Subscribing to ", topic);
|
|
124
149
|
mqtt_subscriptions.push({topic, cb});
|
|
125
150
|
// There may be no client yet - it only connects once it knows which organization's credentials to use
|
|
126
151
|
if (mqtt_client && mqtt_client.connected) {
|
|
127
|
-
mqtt_client.subscribe(topic, (err) =>
|
|
128
|
-
if (err) console.error(err);
|
|
129
|
-
})
|
|
152
|
+
mqtt_client.subscribe(topic, (err, granted) => subscribeResult(topic, err, granted))
|
|
130
153
|
} else {
|
|
131
154
|
console.log("Delaying till connected"); // It will resubscribe from "subscriptions"
|
|
132
155
|
}
|
|
@@ -2055,10 +2078,13 @@ class MqttTopic {
|
|
|
2055
2078
|
type: this.type === 'exponential' ? 'logarithmic' : 'linear',
|
|
2056
2079
|
display: this.type !== 'bool',
|
|
2057
2080
|
title: {
|
|
2081
|
+
display: true, // Chart.js hides a scale title unless asked - without this the axis has no label at all
|
|
2058
2082
|
// noinspection JSUnresolvedReference
|
|
2059
2083
|
color: this.color, // The family colour; the per-line variation happens in MqttGraph.addDataset
|
|
2084
|
+
font: { size: 20 },
|
|
2085
|
+
// The unit is what tells two axes apart when both read as a number - "Temperature (°C)"
|
|
2060
2086
|
// noinspection JSUnresolvedReference
|
|
2061
|
-
text: getString(this.name.replace(/[0-9]+$/,'')),
|
|
2087
|
+
text: getString(this.name.replace(/[0-9]+$/,'')) + (unitSymbol(this.units) ? ` (${unitSymbol(this.units)})` : ''),
|
|
2062
2088
|
},
|
|
2063
2089
|
// 0 is not valid on a logarithmic axis, so fall back to auto-scaling (undefined) rather than 0
|
|
2064
2090
|
// noinspection JSUnresolvedReference
|
|
@@ -2823,9 +2849,7 @@ class MqttClient extends HTMLElementExtended {
|
|
|
2823
2849
|
mqtt_subscriptions.forEach((s) => {
|
|
2824
2850
|
if (!mqtt_client._resubscribeTopics[s.topic]) { // Not really public interface but cleaner console as not needed
|
|
2825
2851
|
console.log("Now connected, subscribing to", s.topic);
|
|
2826
|
-
mqtt_client.subscribe(s.topic, (err) =>
|
|
2827
|
-
if (err) console.error(err);
|
|
2828
|
-
});
|
|
2852
|
+
mqtt_client.subscribe(s.topic, (err, granted) => subscribeResult(s.topic, err, granted));
|
|
2829
2853
|
}
|
|
2830
2854
|
})
|
|
2831
2855
|
} else {
|
package/package.json
CHANGED
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"main": "webcomponents.js",
|
|
31
31
|
"name": "frugal-iot-client",
|
|
32
|
-
"repository":
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/mitra42/frugal-iot-client.git"
|
|
35
|
+
},
|
|
33
36
|
"scripts": {
|
|
34
37
|
"fixtures": "node test/fixtures/regenerate.js",
|
|
35
38
|
"link:hee": "ln -sfn ../../html-element-extended node_modules/html-element-extended && ls -ld node_modules/html-element-extended",
|
|
@@ -38,5 +41,5 @@
|
|
|
38
41
|
"unlink:hee": "rm -f node_modules/html-element-extended && npm install html-element-extended"
|
|
39
42
|
},
|
|
40
43
|
"type": "module",
|
|
41
|
-
"version": "2.0.
|
|
44
|
+
"version": "2.0.3"
|
|
42
45
|
}
|
package/test/graph.test.js
CHANGED
|
@@ -67,3 +67,24 @@ describe('lines sharing a schema colour are told apart', () => {
|
|
|
67
67
|
assert.equal(new Set(colors).size, 3);
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
describe('an axis says what it measures', () => {
|
|
72
|
+
// graphdataset.graph is the route to a graph that does not need a connected element or a canvas
|
|
73
|
+
const axisOf = (mt, g) => { mt.graphdataset = { graph: g }; const id = mt.yaxisid; return g.state.scales[id]; };
|
|
74
|
+
|
|
75
|
+
test('the title is drawn, and carries the unit symbol', () => {
|
|
76
|
+
const { projectMt } = mock.runScenario('one-device');
|
|
77
|
+
const g = new (customElements.get('mqtt-graph'))();
|
|
78
|
+
const axis = axisOf(projectMt.nodes['esp8266-fb94bb'].groups.sht.topics.temperature, g);
|
|
79
|
+
assert.equal(axis.title.display, true);
|
|
80
|
+
assert.equal(axis.title.text, 'Temperature (°C)');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a reading with no unit in the schema gets a bare name', () => {
|
|
84
|
+
const { projectMt } = mock.runScenario('one-device');
|
|
85
|
+
const g = new (customElements.get('mqtt-graph'))();
|
|
86
|
+
const mt = projectMt.nodes['esp8266-fb94bb'].groups.sht.topics.temperature;
|
|
87
|
+
mt.units = undefined;
|
|
88
|
+
assert.equal(axisOf(mt, g).title.text, 'Temperature');
|
|
89
|
+
});
|
|
90
|
+
});
|
package/test/page.test.js
CHANGED
|
@@ -84,7 +84,7 @@ describe('which admin cards a user gets', () => {
|
|
|
84
84
|
assert.deepEqual(cards.adminCardsFor('dev').map((c) => c.section), ['info', 'flash']);
|
|
85
85
|
withCapabilities('READ', 'ADMIN');
|
|
86
86
|
assert.deepEqual(cards.adminCardsFor('dev').map((c) => c.section),
|
|
87
|
-
['info', 'admin', 'projects', 'message', 'retained', 'nodes', 'api']);
|
|
87
|
+
['info', 'admin', 'projects', 'message', 'retained', 'nodes', 'bridges', 'api']);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
test('flashing and pushing an OTA binary are separate capabilities', () => {
|
|
@@ -108,7 +108,7 @@ describe('the project back', () => {
|
|
|
108
108
|
const back = document.createElement('mqtt-projectback');
|
|
109
109
|
back.setAttribute('organization', 'dev');
|
|
110
110
|
document.body.append(back);
|
|
111
|
-
assert.equal(back.querySelectorAll('.fi-admincard').length,
|
|
111
|
+
assert.equal(back.querySelectorAll('.fi-admincard').length, 8);
|
|
112
112
|
back.remove();
|
|
113
113
|
});
|
|
114
114
|
|
|
@@ -228,7 +228,7 @@ describe('the project back', () => {
|
|
|
228
228
|
document.body.append(back);
|
|
229
229
|
back.querySelectorAll('.fi-admincard__head').forEach((h) => h.click());
|
|
230
230
|
const sections = [...back.querySelectorAll('mqtt-admin')].map((a) => a.getAttribute('section'));
|
|
231
|
-
assert.deepEqual(sections, ['admin', 'projects', 'message', 'retained', 'nodes', 'api']);
|
|
231
|
+
assert.deepEqual(sections, ['admin', 'projects', 'message', 'retained', 'nodes', 'bridges', 'api']);
|
|
232
232
|
back.remove();
|
|
233
233
|
});
|
|
234
234
|
});
|