frugal-iot-client 2.0.4 → 2.0.6

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
@@ -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, getStringWith, hasCapability, mqttTempConnect, mqtt_subscribe, retainedPattern, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
10
+ import { CssUrl, DELETE, POST, XXX, brokerHost, configSet, el, getString, getStringWith, hasCapability, mqttTempConnect, mqtt_subscribe, mqtt_unsubscribe, retainedPattern, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
11
11
 
12
12
 
13
13
  // The OTA upload is multipart, which POST() (JSON) cannot carry. Callback-style, like the rest.
@@ -173,8 +173,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
173
173
  ]);
174
174
  }
175
175
  // Fetch ota files and display
176
- getOrDeleteOtaFiles(url) {
176
+ getOrDeleteOtaFiles(url, cb = () => {}) {
177
177
  GET(url, {}, (err, json) => {
178
+ cb();
178
179
  if (err) {
179
180
  this.message(err);
180
181
  return;
@@ -184,11 +185,11 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
184
185
  }
185
186
  });
186
187
  }
187
- getOtaFiles() {
188
- if (this.state.org) {
189
- if (this.otaOrgs.map(o => o[0]).includes(this.state.org)) {
190
- this.getOrDeleteOtaFiles(`/ota_list/${this.state.org}`);
191
- }
188
+ getOtaFiles(cb = () => {}) {
189
+ if (this.state.org && this.otaOrgs.map(o => o[0]).includes(this.state.org)) {
190
+ this.getOrDeleteOtaFiles(`/ota_list/${this.state.org}`, cb);
191
+ } else {
192
+ cb();
192
193
  }
193
194
  }
194
195
  onOtaDelete(val, ev) {
@@ -241,8 +242,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
241
242
  );
242
243
  }
243
244
  // Fetch people and display
244
- getOrChangeAdminPeople(url) {
245
+ getOrChangeAdminPeople(url, cb = () => {}) {
245
246
  GET(url, {}, (err, json) => {
247
+ cb();
246
248
  if (err) {
247
249
  this.message(err.message);
248
250
  return;
@@ -328,9 +330,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
328
330
  onclick: this.onEnrolmentSecretAdd.bind(this)}),
329
331
  ]);
330
332
  }
331
- getEnrolledNodes() {
332
- if (!this.state.org) return;
333
+ getEnrolledNodes(cb = () => {}) {
334
+ if (!this.state.org) { cb(); return; }
333
335
  GET(`/nodes_list/${this.state.org}`, {}, (err, json) => {
336
+ cb();
334
337
  if (err) { this.message(err.message); return; }
335
338
  this.state.enrolled_nodes = json; // [{ project, nodeid, lora, enrolled_at }]
336
339
  this.replaceElement("enrolled_nodes", this.enrolledNodesList());
@@ -345,9 +348,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
345
348
  * itself is up. A Pi with a broken bridge still checks in, and that difference is exactly what
346
349
  * is hard to see from production any other way.
347
350
  */
348
- getBridges() {
349
- if (!this.state.org) return;
351
+ getBridges(cb = () => {}) {
352
+ if (!this.state.org) { cb(); return; }
350
353
  GET(`/bridges_list/${this.state.org}`, {}, (err, json) => {
354
+ cb();
351
355
  if (err) { this.message(err.message); return; }
352
356
  this.state.bridges = json; // [{ site, account, created_at, last_pull }]
353
357
  this.subscribeBridgeState();
@@ -554,9 +558,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
554
558
  const nodes = this.state.enrolled_nodes;
555
559
  if (!nodes || !nodes.length) {
556
560
  return el('p', {textContent:
557
- "No nodes have enrolled yet, and none is asking to. A node enrols itself the first time it " +
558
- "connects; one that asks and is refused is listed here too, so an empty list means nothing " +
559
- "is reaching this server at all - check that the node's org and enrol URL are this one."});
561
+ "No nodes have enrolled yet, and none have asked and been rejected."});
560
562
  }
561
563
  const states = this.nodeStates();
562
564
  return el('table', {class: 'nodestates'}, [
@@ -594,14 +596,17 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
594
596
  }),
595
597
  ]);
596
598
  }
597
- getPeopleList() {
599
+ getPeopleList(cb = () => {}) {
598
600
  if (this.state.org) {
599
- this.getOrChangeAdminPeople(`/people_list/${this.state.org}`);
601
+ this.getOrChangeAdminPeople(`/people_list/${this.state.org}`, cb);
602
+ } else {
603
+ cb();
600
604
  }
601
605
  }
602
606
  // Fetch projects and display
603
- getOrChangeAdminProjects(url) {
607
+ getOrChangeAdminProjects(url, cb = () => {}) {
604
608
  GET(url, {}, (err, json) => {
609
+ cb();
605
610
  if (err) {
606
611
  this.message(err.message);
607
612
  return;
@@ -611,9 +616,11 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
611
616
  }
612
617
  });
613
618
  }
614
- getProjectsList() {
619
+ getProjectsList(cb = () => {}) {
615
620
  if (this.state.org) {
616
- this.getOrChangeAdminProjects(`/projects_list/${this.state.org}`);
621
+ this.getOrChangeAdminProjects(`/projects_list/${this.state.org}`, cb);
622
+ } else {
623
+ cb();
617
624
  }
618
625
  }
619
626
  onPermissionsDelete(val, ev) {
@@ -717,9 +724,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
717
724
  }
718
725
  // Fetch all registered Farm-Platforms, and display. Not org-scoped - a platform is a many-to-many
719
726
  // relationship with orgs/projects, recorded per-mapping in api_farms (see getFarmsList).
720
- getPlatformsList() {
727
+ getPlatformsList(cb = () => {}) {
721
728
  if (this.state.org) {
722
729
  GET(`/api/platforms/list`, {}, (err, json) => {
730
+ cb();
723
731
  if (err) {
724
732
  this.message(err.message);
725
733
  return;
@@ -733,6 +741,8 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
733
741
  this.replaceElement("platforms_list_display", this.platformsListDisplay());
734
742
  this.replaceElement("add_farm", this.collapsibleArea('add_farm', "Add Farm", this.farmRegisterForm));
735
743
  });
744
+ } else {
745
+ cb();
736
746
  }
737
747
  }
738
748
  onPlatformSelect(ev) {
@@ -802,9 +812,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
802
812
  return el('div', {}, [el('h3', {textContent: "Register Platform"}), EL]);
803
813
  }
804
814
  // Fetch farm-to-project mappings registered for the selected organization, and display
805
- getFarmsList() {
815
+ getFarmsList(cb = () => {}) {
806
816
  if (this.state.org) {
807
817
  GET(`/api/farms/list?org=${encodeURIComponent(this.state.org)}`, {}, (err, json) => {
818
+ cb();
808
819
  if (err) {
809
820
  this.message(err.message);
810
821
  return;
@@ -819,6 +830,8 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
819
830
  this.replaceElement("farm_node_actions", this.farmNodeActions());
820
831
  this.getDeviceActionSchema();
821
832
  });
833
+ } else {
834
+ cb();
822
835
  }
823
836
  }
824
837
  onFarmSelect(ev) {
@@ -907,13 +920,13 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
907
920
  // server_config locally - this is the API tab, so it should exercise the same API a real
908
921
  // Farm-Platform would use. A farm can map to more than one project, so one request per project id,
909
922
  // joined into a single row set once all have replied.
910
- getFarmNodesList() {
923
+ getFarmNodesList(cb = () => {}) {
911
924
  const org = this.state.org;
912
925
  const projectIds = this.selectedFarmProjectIds();
913
926
  if (!org || projectIds.length === 0) {
914
927
  this.state.farm_nodes_list = [];
915
- this.state.enrolled_nodes = null;
916
928
  this.replaceElement("farm_nodes_table", this.farmNodesTable());
929
+ cb();
917
930
  return;
918
931
  }
919
932
  let remaining = projectIds.length;
@@ -936,6 +949,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
936
949
  });
937
950
  }
938
951
  if (--remaining === 0) {
952
+ cb();
939
953
  this.state.farm_nodes_list = allNodes;
940
954
  this.replaceElement("farm_nodes_table", this.farmNodesTable());
941
955
  }
@@ -999,15 +1013,17 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
999
1013
  }
1000
1014
  // Fetch the selected node's Device Schema so the Action section can list its actions - triggered
1001
1015
  // whenever the selected node changes.
1002
- getDeviceActionSchema() {
1016
+ getDeviceActionSchema(cb = () => {}) {
1003
1017
  const device = this.selectedFarmNodeDevice();
1004
1018
  this.state.device_schema = null;
1005
1019
  this.state.selected_action = null;
1006
1020
  if (!device) {
1007
1021
  this.replaceElement("action_section", this.actionSection());
1022
+ cb();
1008
1023
  return;
1009
1024
  }
1010
1025
  GET(`/api/devices/schema?device=${encodeURIComponent(device)}`, {}, (err, json) => {
1026
+ cb();
1011
1027
  if (err) {
1012
1028
  this.message(err.message);
1013
1029
  return;
@@ -1169,17 +1185,21 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1169
1185
  }
1170
1186
  // Each of these lists is the result of one fetch made when the card opened - nothing pushes a
1171
1187
  // change, so the heading carries the way to ask again.
1188
+ // refreshFn is given a callback for when its reply has arrived, which is what stops the spin.
1172
1189
  refreshableHeading(title, refreshFn) {
1173
- return el('h3', {}, [
1174
- el('span', {textContent: title}),
1175
- el('span', {class: 'pseudolink refresh', i8n: false, title: getString("Refresh"),
1176
- textContent: ' \u27f3', onclick: refreshFn.bind(this)}),
1177
- ]);
1190
+ const iconEl = el('span', {class: 'pseudolink refresh', i8n: false, title: getString("Refresh"),
1191
+ textContent: '\u27f3', onclick: () => {
1192
+ if (iconEl.classList.contains('busy')) return;
1193
+ iconEl.classList.add('busy');
1194
+ refreshFn.call(this, () => iconEl.classList.remove('busy'));
1195
+ }});
1196
+ return el('h3', {}, [el('span', {textContent: title}), iconEl]);
1178
1197
  }
1179
1198
  // The organization's nodes are read out of server_config, so refreshing this table means fetching
1180
1199
  // /config.json again rather than re-rendering what is already here.
1181
- refreshNodesTable() {
1200
+ refreshNodesTable(cb = () => {}) {
1182
1201
  GET("/config.json", {}, (err, json) => {
1202
+ cb();
1183
1203
  if (err) { this.message(err.message); return; }
1184
1204
  configSet(json);
1185
1205
  this.replaceElement("nodes_table", this.nodesTable());
@@ -1204,9 +1224,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1204
1224
  this.adminSections().forEach((section) => this.replaceElement(section.rest, this.gatedContent(section)));
1205
1225
  // Which tabs do their own network fetch, and so need one again for a new organization. Only
1206
1226
  // Dashboard does not (mqtt-wrapper handles it). "Nodes" is in the list because it now fetches
1207
- // the enrolled-node list as well as reading server_config - leaving it out was why that section
1208
- // always said "No nodes have enrolled yet": loadSectionIfNeeded returns early for a tab that is not
1209
- // in this set, so the fetch never happened.
1227
+ // the enrolled-node list as well as reading server_config:
1210
1228
  this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'Bridges', 'API']);
1211
1229
  this.loadSectionIfNeeded(this.state.activeSectionTitle);
1212
1230
  // Only a card created without an organization has a dropdown of its own; replaceElement does
@@ -1393,32 +1411,55 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1393
1411
  }
1394
1412
 
1395
1413
  onNodeProjectChange(nodeId, oldProjectId, selectElement, onChanged) {
1396
- // Handle project change for a node
1397
1414
  const newProjectId = selectElement.value;
1398
1415
  if (newProjectId === oldProjectId) {
1399
1416
  return; // No change
1400
1417
  }
1418
+ const org = this.state.org;
1419
+ const topic = `${org}/${oldProjectId}/${nodeId}/set/frugal_iot/project`;
1420
+ console.log(`Publishing ${topic} = ${newProjectId}`);
1421
+ // Not retained: a sleeping node still gets it, from its persistent session, when it wakes.
1422
+ mqtt_client.publish(topic, newProjectId, {retain: false, qos: 1});
1423
+ this.message(getStringWith("Asked %s to move", nodeId));
1424
+ this.watchNodeMove(org, nodeId, oldProjectId, newProjectId, onChanged);
1425
+ onChanged();
1426
+ }
1401
1427
 
1402
- // Send MQTT message to update project
1403
- const topic = `${this.state.org}/${oldProjectId}/${nodeId}/set/frugal_iot/project`;
1404
- const message = newProjectId;
1405
-
1406
- console.log(`Publishing ${topic} = ${message}`);
1407
- mqtt_client.publish(topic, message, {retain: false, qos: 1});
1408
- this.message(`${getString("Project changed to")} ${newProjectId} ${getString("for node")} ${nodeId}`);
1428
+ // The move is done when the node is heard at its new address - nothing else says so, because the
1429
+ // node restarts and enrols afresh there, and the server tidies the old address away only then.
1430
+ watchNodeMove(org, nodeId, fromProjectId, toProjectId, onChanged) {
1431
+ const moves = this.state.nodeMoves || (this.state.nodeMoves = {});
1432
+ if (moves[nodeId]) { mqtt_unsubscribe(moves[nodeId].watch); }
1433
+ const watch = `${org}/${toProjectId}/${nodeId}/#`;
1434
+ moves[nodeId] = {from: fromProjectId, to: toProjectId, watch};
1435
+ mqtt_subscribe(watch, (topic, message, retained) => {
1436
+ // A retained message is the broker's memory of an earlier stay in that project, not this node
1437
+ if (retained || moves[nodeId]?.watch !== watch) { return; }
1438
+ delete moves[nodeId];
1439
+ mqtt_unsubscribe(watch);
1440
+ this.moveNodeInConfig(org, nodeId, fromProjectId, toProjectId);
1441
+ onChanged();
1442
+ });
1443
+ }
1409
1444
 
1410
- // Refresh the table after a short delay
1411
- setTimeout(onChanged, 500);
1445
+ // server_config is only fetched at startup, so it is told here what the server now believes.
1446
+ moveNodeInConfig(org, nodeId, fromProjectId, toProjectId) {
1447
+ const projects = this.projectsOf(org);
1448
+ const from = projects[fromProjectId]?.nodes;
1449
+ const to = projects[toProjectId] && (projects[toProjectId].nodes || (projects[toProjectId].nodes = {}));
1450
+ if (!to) { return; }
1451
+ to[nodeId] = {...(from?.[nodeId] || {}), lastseen: new Date().toISOString()};
1452
+ if (from) { delete from[nodeId]; }
1412
1453
  }
1413
1454
 
1414
1455
  renderProjectIdCell(node, onChanged) {
1415
- // Create an editable cell for projectId that opens a dropdown on click
1456
+ const move = this.state.nodeMoves?.[node.nodeId];
1457
+ const pending = move && move.from === node.projectId;
1416
1458
  return el('td', {
1417
1459
  style: 'cursor: pointer; position: relative;',
1418
- title: 'Click to change project',
1460
+ title: pending ? getStringWith("Waiting for %s to report", node.nodeId) : getString("Click to change project"),
1419
1461
  onclick: (e) => {
1420
- // Replace with dropdown
1421
- const dropdown = this.projectsDropdownForNode(this.state.org, node.projectId);
1462
+ const dropdown = this.projectsDropdownForNode(this.state.org, pending ? move.to : node.projectId);
1422
1463
  dropdown.addEventListener('change', (changeEvent) => {
1423
1464
  this.onNodeProjectChange(node.nodeId, node.projectId, changeEvent.target, onChanged);
1424
1465
  });
@@ -1427,7 +1468,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1427
1468
  // Close dropdown if focus lost
1428
1469
  dropdown.addEventListener('blur', onChanged);
1429
1470
  }
1430
- }, [node.projectId]);
1471
+ }, [pending ? `${node.projectId} → ${move.to} …` : node.projectId]);
1431
1472
  }
1432
1473
 
1433
1474
  // Content of the OTA tab below the organization dropdown - only rendered once an org is selected
@@ -1627,20 +1668,17 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1627
1668
  el('section', {}, [
1628
1669
  this.refreshableHeading("Enrolled nodes", this.getEnrolledNodes),
1629
1670
  el('p', {textContent:
1630
- "Each node has its own broker credential, which it collects from this server the first " +
1631
- "time it connects. Forget one to have it issued a new credential - needed if its " +
1632
- "filesystem has been erased, because it can then no longer prove which node it is. A " +
1633
- "node that asked and was refused is listed here as well, with why: click Approve on it " +
1634
- "to accept its next request whatever secret it presents. It retries every half hour."}),
1671
+ "Nodes that have obtained broker credentials on first connection. " +
1672
+ "Forget reissues a new credential at next connection and is needed if memory has been erased. " +
1673
+ "Refused nodes are listed - click Approve to allow (on next retry, typically 30 minutes)."}),
1635
1674
  this.state.elements.enrolled_nodes = this.enrolledNodesList(),
1636
1675
  ]),
1637
1676
  el('section', {}, [
1638
1677
  el('h3', {textContent: "Enrolment secret"}),
1639
- el('p', {textContent:
1640
- "Compiled into a node's firmware, this is what lets it ask this server for its own " +
1641
- "broker credential. It grants nothing else - no reading and no publishing. Add a second " +
1642
- "one to change it: both are accepted, so nodes already flashed keep working until you " +
1643
- "withdraw the old one."}),
1678
+ el('p', {i8n: false, textContent: getString(
1679
+ "Compiled into firmware and used to request broker credential. " +
1680
+ "Add a second one to change it: both are accepted, so nodes already flashed keep working until you " +
1681
+ "withdraw the old one.")}),
1644
1682
  this.state.elements.enrolment_secrets = this.enrolmentSecretsList(),
1645
1683
  ]),
1646
1684
  ]);
package/base/boards.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": 7,
2
+ "version": 8,
3
3
  "coreVersion": "pioarduino framework-arduinoespressif32 3.3.9",
4
- "comment": "Base bundles for USB flashing - see FLASH_PLAN.md. Bootloaders MUST come from the same framework the firmware is built against: generated from pioarduino framework-arduinoespressif32-libs/<chip>/bin/bootloader_qio_80m.elf via 'esptool elf2image --flash-mode dio --flash-freq 80m --flash-size 4MB'. The C3 one is byte-identical to a real pioarduino build; S2/S3 use the same recipe but are unverified against a reference build. The Arduino IDE core's dio_80m ELF produces a DIFFERENT bootloader that did not work. Flash-size independent (esptool patches the header at write time). Partition tables are chip-independent. boot_app0.bin is universal.",
4
+ "comment": "Base bundles for USB flashing - see FLASH_PLAN.md. Bootloaders MUST come from the same framework the firmware is built against: generated from pioarduino framework-arduinoespressif32-libs/<chip>/bin/bootloader_qio_80m.elf via 'esptool elf2image --flash-mode dio --flash-freq 80m --flash-size 4MB'. The C3 one is byte-identical to a real pioarduino build; S2/S3 use the same recipe but are unverified against a reference build. The Arduino IDE core's dio_80m ELF produces a DIFFERENT bootloader that did not work. Flash-size independent (esptool patches the header at write time). Partition tables are chip-independent, and are the core's stock CSVs, so they match what a PlatformIO env using the same scheme writes. boot_app0.bin is universal.",
5
5
  "otadata": "boot_app0.bin",
6
6
  "partitionTableOffset": 32768,
7
7
  "bootloaderByChip": {
@@ -13,13 +13,13 @@
13
13
  "ESP32-C3-4MB": "min_spiffs",
14
14
  "ESP32-S2-4MB": "min_spiffs",
15
15
  "ESP32-S3-4MB": "min_spiffs",
16
- "ESP32-S3-8MB": "frugal_8mb",
17
- "ESP32-S3-16MB": "frugal_16mb"
16
+ "ESP32-S3-8MB": "default_8MB",
17
+ "ESP32-S3-16MB": "default_16MB"
18
18
  },
19
19
  "partitionsByScheme": {
20
20
  "min_spiffs": "partitions/min_spiffs.bin",
21
- "frugal_8mb": "partitions/frugal_8mb.bin",
22
- "frugal_16mb": "partitions/frugal_16mb.bin"
21
+ "default_8MB": "partitions/default_8MB.bin",
22
+ "default_16MB": "partitions/default_16MB.bin"
23
23
  },
24
24
  "wholeImageChips": [
25
25
  "ESP8266"
@@ -0,0 +1,7 @@
1
+ # Name, Type, SubType, Offset, Size, Flags
2
+ nvs, data, nvs, 0x9000, 0x5000,
3
+ otadata, data, ota, 0xe000, 0x2000,
4
+ app0, app, ota_0, 0x10000, 0x640000,
5
+ app1, app, ota_1, 0x650000,0x640000,
6
+ spiffs, data, spiffs, 0xc90000,0x360000,
7
+ coredump, data, coredump,0xFF0000,0x10000,
@@ -0,0 +1,7 @@
1
+ # Name, Type, SubType, Offset, Size, Flags
2
+ nvs, data, nvs, 0x9000, 0x5000,
3
+ otadata, data, ota, 0xe000, 0x2000,
4
+ app0, app, ota_0, 0x10000, 0x330000,
5
+ app1, app, ota_1, 0x340000,0x330000,
6
+ spiffs, data, spiffs, 0x670000,0x180000,
7
+ coredump, data, coredump,0x7F0000,0x10000,
package/cards.js CHANGED
@@ -384,11 +384,20 @@ class MqttDeviceCard extends HTMLElementExtendedMinimum {
384
384
  this.compact(hyst, {}),
385
385
  ]) : null,
386
386
  ].filter(Boolean)) : null;
387
+ // Any other control - a display's inputs, Blinken's timings - gets a plain row per topic, or its
388
+ // section came out empty and was dropped, leaving nothing to wire (control_lcd_ht did exactly that)
389
+ const laidOut = ['now', 'greater', 'limit', 'hysteresis', 'hysterisis', 'out', 'manual'];
390
+ const others = Object.entries(t).filter(([leaf]) => !laidOut.includes(leaf)).map(([, mt]) => {
391
+ const widget = this.widgetForTopic(mt, '', mt.wireable ? { wiring: 'open' } : {});
392
+ this.state.elements.widgets.push({ row: { mt }, widget });
393
+ return this.field(mt.name, widget);
394
+ });
387
395
  return this.section(groupMt.state.name || groupId, [
388
396
  t.now ? this.field(getString('Input'), this.widgetForTopic(t.now, '', { wiring: 'open' })) : null,
389
397
  when ? this.field(getString('When'), when) : null,
390
398
  t.out ? this.field(getString('Output'), this.widgetForTopic(t.out, '', { wiring: 'open' })) : null,
391
399
  t.manual ? this.field(getString('Manual'), this.widgetForTopic(t.manual, '')) : null,
400
+ ...others,
392
401
  ]);
393
402
  }
394
403
  // A bare input with no label of its own, for the inline "when" row