frugal-iot-client 2.0.2 → 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 CHANGED
@@ -365,6 +365,59 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
365
365
  }),
366
366
  ]);
367
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
+ }
368
421
  bridgesRestContent() {
369
422
  return el('div', {}, [
370
423
  this.refreshableHeading("Bridged Pis", this.getBridges),
@@ -375,6 +428,18 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
375
428
  "this organization's logins, which it does over HTTP - so a Pi can be checking in while " +
376
429
  "its bridge is down, and the two together say which half is broken."}),
377
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
+ ]),
378
443
  ]);
379
444
  }
380
445
  onNodeReset(project, nodeid) {
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,6 +118,25 @@ function unshiftUnique(arr, v) {
118
118
  }
119
119
  */
120
120
 
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
+
121
140
  /*
122
141
  * Subscribe to a topic. cb(topic, message, retained) - see mqtt_deliver, which passes all three.
123
142
  *
@@ -130,9 +149,7 @@ function mqtt_subscribe(topic, cb) { // cb(topic, message, retained)
130
149
  mqtt_subscriptions.push({topic, cb});
131
150
  // There may be no client yet - it only connects once it knows which organization's credentials to use
132
151
  if (mqtt_client && mqtt_client.connected) {
133
- mqtt_client.subscribe(topic, (err) => {
134
- if (err) console.error(err);
135
- })
152
+ mqtt_client.subscribe(topic, (err, granted) => subscribeResult(topic, err, granted))
136
153
  } else {
137
154
  console.log("Delaying till connected"); // It will resubscribe from "subscriptions"
138
155
  }
@@ -2061,10 +2078,13 @@ class MqttTopic {
2061
2078
  type: this.type === 'exponential' ? 'logarithmic' : 'linear',
2062
2079
  display: this.type !== 'bool',
2063
2080
  title: {
2081
+ display: true, // Chart.js hides a scale title unless asked - without this the axis has no label at all
2064
2082
  // noinspection JSUnresolvedReference
2065
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)"
2066
2086
  // noinspection JSUnresolvedReference
2067
- text: getString(this.name.replace(/[0-9]+$/,'')),
2087
+ text: getString(this.name.replace(/[0-9]+$/,'')) + (unitSymbol(this.units) ? ` (${unitSymbol(this.units)})` : ''),
2068
2088
  },
2069
2089
  // 0 is not valid on a logarithmic axis, so fall back to auto-scaling (undefined) rather than 0
2070
2090
  // noinspection JSUnresolvedReference
@@ -2829,9 +2849,7 @@ class MqttClient extends HTMLElementExtended {
2829
2849
  mqtt_subscriptions.forEach((s) => {
2830
2850
  if (!mqtt_client._resubscribeTopics[s.topic]) { // Not really public interface but cleaner console as not needed
2831
2851
  console.log("Now connected, subscribing to", s.topic);
2832
- mqtt_client.subscribe(s.topic, (err) => {
2833
- if (err) console.error(err);
2834
- });
2852
+ mqtt_client.subscribe(s.topic, (err, granted) => subscribeResult(s.topic, err, granted));
2835
2853
  }
2836
2854
  })
2837
2855
  } else {
package/package.json CHANGED
@@ -41,5 +41,5 @@
41
41
  "unlink:hee": "rm -f node_modules/html-element-extended && npm install html-element-extended"
42
42
  },
43
43
  "type": "module",
44
- "version": "2.0.2"
44
+ "version": "2.0.3"
45
45
  }
@@ -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, 7);
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
  });