homebridge-smartsystem 7.1.24 → 7.1.26

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.
@@ -8,6 +8,7 @@
8
8
  "sourceMaps": true,
9
9
  "name": "Test HB Platform",
10
10
  "program": "${workspaceFolder}/testHB.js",
11
+ "runtimeExecutable": "/Users/johan/.nvm/versions/node/v20.19.5/bin/node",
11
12
  "skipFiles": [
12
13
  "<node_internals>/**"
13
14
  ]
@@ -19,6 +20,7 @@
19
20
  "sourceMaps": true,
20
21
  "name": "Run SmartSocket Proxy",
21
22
  "program": "${workspaceFolder}/testProxy.js",
23
+ "runtimeExecutable": "/Users/johan/.nvm/versions/node/v20.19.5/bin/node",
22
24
  "skipFiles": [
23
25
  "<node_internals>/**"
24
26
  ]
@@ -30,6 +32,7 @@
30
32
  "sourceMaps": true,
31
33
  "name": "Run Test Websockets",
32
34
  "program": "${workspaceFolder}/testWS.js",
35
+ "runtimeExecutable": "/Users/johan/.nvm/versions/node/v20.19.5/bin/node",
33
36
  "skipFiles": [
34
37
  "<node_internals>/**"
35
38
  ]
package/duotecno/types.js CHANGED
@@ -167,8 +167,9 @@ var LinkType;
167
167
  (function (LinkType) {
168
168
  LinkType["kNoType"] = "";
169
169
  LinkType["kAccessory"] = "accessory";
170
+ LinkType["kOverkiz"] = "overkiz";
170
171
  })(LinkType = exports.LinkType || (exports.LinkType = {}));
171
- exports.kEmptyLink = Object.assign(Object.assign({}, exports.kEmptyUnit), { type: LinkType.kNoType, max: 100, min: 0, accName: "acc", accId: "", accService: [] });
172
+ exports.kEmptyLink = Object.assign(Object.assign({}, exports.kEmptyUnit), { type: LinkType.kNoType, max: 100, min: 0, nostop: "N", accName: "acc", accId: "", accService: [] });
172
173
  /////////////
173
174
  // Devices //
174
175
  /////////////
@@ -342,6 +343,7 @@ exports.Sanitizers = {
342
343
  link.type = aLink.type || exports.kEmptyLink.type;
343
344
  link.max = aLink.max || exports.kEmptyLink.max;
344
345
  link.min = aLink.min || exports.kEmptyLink.min;
346
+ link.nostop = aLink.nostop || exports.kEmptyLink.nostop;
345
347
  return link;
346
348
  },
347
349
  deviceConfig: function (aDevice) {
@@ -400,7 +402,8 @@ exports.Sanitizers = {
400
402
  return exports.Sanitizers.linkConfig({ name: aLink.name, unitName: aLink.unitName,
401
403
  masterAddress: aLink.masterAddress, masterPort: aLink.masterPort,
402
404
  logicalAddress: aLink.logicalAddress, logicalNodeAddress: aLink.logicalNodeAddress,
403
- accName: aLink.accName, accService: aLink.accService, accId: aLink.accId, type: aLink.type });
405
+ accName: aLink.accName, accService: aLink.accService, accId: aLink.accId, type: aLink.type,
406
+ max: aLink.max, min: aLink.min, nostop: aLink.nostop });
404
407
  },
405
408
  ruleConfig(rule) {
406
409
  rule = Object.assign({}, rule);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-smartsystem",
3
3
  "displayname": "Duotecno Bridge",
4
- "version": "7.1.24",
4
+ "version": "7.1.26",
5
5
  "description": "SmartServer (Proxy TCP sockets to the cloud, Smappee MQTT, Duotecno IP Nodes, Homekit interface)",
6
6
  "main": "index.js",
7
7
  "author": "Johan Coppieters",
package/server/overkiz.js CHANGED
@@ -8,11 +8,32 @@
8
8
  // then polling its HomeKit accessories back into Duotecno via server/HB.ts
9
9
  // and the Link mechanism in smartapp.ts.
10
10
  //
11
- // Phase 1 (this file): connect, discover devices, send open/close/stop
12
- // commands, and emit translated Duotecno-style states when Overkiz reports
13
- // a change. Wiring this into the Link UI (so a Duotecno unit can be coupled
14
- // to a specific Somfy screen) is a follow-up step once connectivity against
15
- // real hardware is confirmed.
11
+ // Connects, discovers devices, sends open/close/stop/dim commands, and emits
12
+ // translated Duotecno-style states when Overkiz reports a change. Wired into
13
+ // the Link UI in smartapp.ts so a Duotecno up/down or dimmer unit can be
14
+ // coupled to a specific Overkiz device.
15
+ //
16
+ // Verified against real hardware (a TaHoma box with Screen and Awning
17
+ // devices): both widget types expose a genuine 'stop' command, unlike the
18
+ // Homebridge-accessory link path (server/HB.ts) which has to fake a stop by
19
+ // re-setting the last known (and often unreliable) position.
20
+ //
21
+ // Screen devices (uiClass "Screen", e.g. roller shutters/blinds) use
22
+ // open/close/stop/setClosure. Awning devices (uiClass "Awning", e.g. sun
23
+ // awnings) use a different command set: undeploy/deploy/stop/setDeployment
24
+ // (there is no "open"/"close" on an Awning). Light devices (uiClass "Light")
25
+ // only expose setIntensity (0-100), no separate on/off.
26
+ //
27
+ // Known overkiz-client (1.0.23) quirk: Client.execute() stores the plain
28
+ // {label, actions} object we pass it into its internal executionPool, but
29
+ // fetchEvents() later assumes pooled entries are Execution instances and
30
+ // calls execution.onStateUpdate(), which throws for our commands ("Polling
31
+ // error - TypeError: execution.onStateUpdate is not a function"). That's
32
+ // caught inside the library and only affects completion-tracking of our own
33
+ // outgoing commands (externally-triggered state changes, e.g. via the Somfy
34
+ // app or remote, aren't affected and still push through normally) -- but to
35
+ // get a reliable resulting state for OUR commands we explicitly refresh the
36
+ // device afterwards instead of trusting that push.
16
37
  //
17
38
  // Johan Coppieters, 2026.
18
39
  var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
@@ -31,10 +52,15 @@ const overkiz_client_1 = require("overkiz-client");
31
52
  const types_1 = require("../duotecno/types");
32
53
  const logger_1 = require("../duotecno/logger");
33
54
  const base_1 = require("./base");
34
- // core:ClosureState / core:ClosureOrRollerShutterPositionState: 0 = fully
35
- // open, 100 = fully closed (opposite of HomeKit's TargetPosition scale).
55
+ // Position state: 0 = fully open/undeployed, 100 = fully closed/deployed
56
+ // (opposite of HomeKit's TargetPosition scale). Screens report
57
+ // core:ClosureState, Awnings report core:DeploymentState instead.
58
+ const kPositionStateNames = ["core:ClosureState", "core:DeploymentState"];
59
+ const kIntensityStateNames = ["core:LightIntensityState", "core:IntensityState"];
36
60
  const kOpenThreshold = 5;
37
61
  const kClosedThreshold = 95;
62
+ const kAwningUiClass = "Awning";
63
+ const kIgnoredUiClasses = ["ProtocolGateway"]; // bridges/gateways: not a controllable end-device
38
64
  function closureToUnitState(closure, previousClosure) {
39
65
  if (closure <= kOpenThreshold)
40
66
  return types_1.UnitState.kOpen;
@@ -62,11 +88,25 @@ class Overkiz extends base_1.Base {
62
88
  (0, logger_1.log)("overkiz", "Overkiz not configured (missing user/password) -> not starting.");
63
89
  }
64
90
  }
91
+ // (Re)connect using the current config -- used at startup and after the
92
+ // settings page saves new credentials, so a full process restart isn't
93
+ // required to pick them up.
65
94
  connect() {
66
95
  return __awaiter(this, void 0, void 0, function* () {
67
96
  const bridge = {
68
97
  warn: (...args) => (0, logger_1.log)("overkiz", args.join(" ")),
69
- error: (...args) => (0, logger_1.err)("overkiz", args.join(" ")),
98
+ error: (...args) => {
99
+ const msg = args.join(" ");
100
+ if (msg.includes("onStateUpdate is not a function")) {
101
+ // the known overkiz-client executionPool quirk described up top --
102
+ // harmless (the command still executes fine), just log it quietly
103
+ // instead of as an error.
104
+ (0, logger_1.debug)("overkiz", msg);
105
+ }
106
+ else {
107
+ (0, logger_1.err)("overkiz", msg);
108
+ }
109
+ },
70
110
  debug: (...args) => (0, logger_1.debug)("overkiz", args.join(" ")),
71
111
  info: (...args) => (0, logger_1.log)("overkiz", args.join(" ")),
72
112
  log: (...args) => (0, logger_1.log)("overkiz", args.join(" ")),
@@ -79,32 +119,46 @@ class Overkiz extends base_1.Base {
79
119
  refreshPeriod: this.config.refreshPeriod || 60,
80
120
  });
81
121
  const setup = yield this.client.getSetup();
122
+ this.devices.clear();
123
+ this.closures.clear();
82
124
  setup.devices.forEach(device => this.attachDevice(device));
83
125
  (0, logger_1.log)("overkiz", `connected -> ${setup.devices.length} device(s) found`);
84
126
  });
85
127
  }
86
128
  attachDevice(device) {
87
129
  this.devices.set(device.deviceURL, device);
88
- const closure = device.getNumber("core:ClosureState");
89
- if (device.hasState("core:ClosureState"))
90
- this.closures.set(device.deviceURL, closure);
130
+ const stateName = kPositionStateNames.find(n => device.hasState(n));
131
+ if (stateName)
132
+ this.closures.set(device.deviceURL, device.getNumber(stateName));
91
133
  device.on("states", (states) => this.onStates(device, states));
92
134
  }
93
135
  onStates(device, states) {
94
136
  (0, logger_1.debug)("overkiz", `states for ${device.label} -> ${JSON.stringify(states)}`);
95
- const closureState = states.find(s => s.name === "core:ClosureState");
96
- if (closureState) {
97
- const closure = Number(closureState.value);
137
+ const positionState = states.find(s => kPositionStateNames.includes(s.name));
138
+ if (positionState) {
139
+ const closure = Number(positionState.value);
98
140
  const previous = this.closures.get(device.deviceURL);
99
141
  const unitState = closureToUnitState(closure, previous);
100
142
  this.closures.set(device.deviceURL, closure);
101
- (0, logger_1.log)("overkiz", `${device.label} -> closure ${closure} -> UnitState ${types_1.UnitState[unitState]}`);
143
+ (0, logger_1.log)("overkiz", `${device.label} -> position ${closure} -> UnitState ${types_1.UnitState[unitState]}`);
102
144
  this.emitter.emit("state", device.deviceURL, unitState, closure);
103
145
  }
146
+ const intensityState = states.find(s => kIntensityStateNames.includes(s.name));
147
+ if (intensityState) {
148
+ const intensity = Number(intensityState.value);
149
+ (0, logger_1.log)("overkiz", `${device.label} -> intensity ${intensity}`);
150
+ this.emitter.emit("intensity", device.deviceURL, intensity);
151
+ }
152
+ }
153
+ uiClass(deviceURL) {
154
+ var _a;
155
+ return ((_a = this.devices.get(deviceURL)) === null || _a === void 0 ? void 0 : _a.definition.uiClass) || "";
104
156
  }
105
157
  listDevices() {
106
- return Array.from(this.devices.values()).map(d => ({
107
- deviceURL: d.deviceURL, label: d.label, widget: d.definition.widgetName,
158
+ return Array.from(this.devices.values())
159
+ .filter(d => !kIgnoredUiClasses.includes(d.definition.uiClass))
160
+ .map(d => ({
161
+ deviceURL: d.deviceURL, label: d.label, widget: d.definition.widgetName, uiClass: d.definition.uiClass,
108
162
  }));
109
163
  }
110
164
  execute(deviceURL, commandName, parameters = []) {
@@ -116,26 +170,46 @@ class Overkiz extends base_1.Base {
116
170
  label: "smartsocket",
117
171
  actions: [{ deviceURL, commands: [{ name: commandName, parameters }] }],
118
172
  });
173
+ // work around the executionPool/onStateUpdate quirk described above: get
174
+ // an authoritative resulting state ourselves rather than trusting the
175
+ // (possibly swallowed) push event for our own command.
176
+ this.client.refreshDeviceStates(deviceURL).catch(e => (0, logger_1.debug)("overkiz", `refreshDeviceStates failed: ${e.message || e}`));
119
177
  });
120
178
  }
179
+ hasCommand(deviceURL, commandName) {
180
+ var _a;
181
+ return ((_a = this.devices.get(deviceURL)) === null || _a === void 0 ? void 0 : _a.hasCommand(commandName)) || false;
182
+ }
121
183
  up(deviceURL) {
122
184
  return __awaiter(this, void 0, void 0, function* () {
123
- yield this.execute(deviceURL, "open");
185
+ yield this.execute(deviceURL, (this.uiClass(deviceURL) === kAwningUiClass) ? "undeploy" : "open");
124
186
  });
125
187
  }
126
188
  down(deviceURL) {
127
189
  return __awaiter(this, void 0, void 0, function* () {
128
- yield this.execute(deviceURL, "close");
190
+ yield this.execute(deviceURL, (this.uiClass(deviceURL) === kAwningUiClass) ? "deploy" : "close");
129
191
  });
130
192
  }
193
+ // Only send a real 'stop' -- never fake one (that's the whole point of this
194
+ // native integration over the Homebridge-accessory link path). If this
195
+ // particular device doesn't expose a 'stop' command, do nothing.
131
196
  stop(deviceURL) {
132
197
  return __awaiter(this, void 0, void 0, function* () {
198
+ if (!this.hasCommand(deviceURL, "stop")) {
199
+ (0, logger_1.log)("overkiz", `device ${deviceURL} has no native 'stop' command -> not sending anything`);
200
+ return;
201
+ }
133
202
  yield this.execute(deviceURL, "stop");
134
203
  });
135
204
  }
136
205
  setClosure(deviceURL, percentClosed) {
137
206
  return __awaiter(this, void 0, void 0, function* () {
138
- yield this.execute(deviceURL, "setClosure", [percentClosed]);
207
+ yield this.execute(deviceURL, (this.uiClass(deviceURL) === kAwningUiClass) ? "setDeployment" : "setClosure", [percentClosed]);
208
+ });
209
+ }
210
+ setIntensity(deviceURL, percent) {
211
+ return __awaiter(this, void 0, void 0, function* () {
212
+ yield this.execute(deviceURL, "setIntensity", [percent]);
139
213
  });
140
214
  }
141
215
  }
@@ -25,6 +25,7 @@ const logger_1 = require("../duotecno/logger");
25
25
  const protocol_1 = require("../duotecno/protocol");
26
26
  const somfy = require("./somfy");
27
27
  const fs_1 = require("fs");
28
+ const overkiz_1 = require("./overkiz");
28
29
  const config_1 = require("../duotecno/config");
29
30
  const HB_1 = require("./HB");
30
31
  const HA_API_1 = require("./HA-API");
@@ -516,6 +517,7 @@ function SmartSocketInit(message){
516
517
  // Settings //
517
518
  //////////////
518
519
  doSettings(context) {
520
+ var _a;
519
521
  return __awaiter(this, void 0, void 0, function* () {
520
522
  let config = this.read("settings");
521
523
  let message = "";
@@ -528,6 +530,9 @@ function SmartSocketInit(message){
528
530
  // Note: Full restart recommended for mDNS changes to take effect properly
529
531
  (0, logger_1.log)("smartapp", "Settings saved. mDNS name updated to: " + config.mdnsName);
530
532
  }
533
+ const overkizMessage = yield this.saveOverkizSettings(context);
534
+ if (overkizMessage)
535
+ message += " " + overkizMessage;
531
536
  }
532
537
  else if (context.action === "restart") {
533
538
  context.request = "restart";
@@ -536,7 +541,9 @@ function SmartSocketInit(message){
536
541
  else {
537
542
  // Just display the settings form
538
543
  }
539
- return this.ejs("settings", context, { config, message });
544
+ const overkizConfig = ((_a = this.power.overkiz) === null || _a === void 0 ? void 0 : _a.config) || types_1.Sanitizers.overkiz(null);
545
+ const overkizDevices = this.power.overkiz ? this.power.overkiz.listDevices() : [];
546
+ return this.ejs("settings", context, { config, message, overkizConfig, overkizDevices });
540
547
  });
541
548
  }
542
549
  scrapeSettings(context) {
@@ -683,6 +690,7 @@ function SmartSocketInit(message){
683
690
  (0, logger_1.log)("smartapp", "** error ** missing unit: " + (0, types_1.hex)(link.logicalNodeAddress) + "/" + (0, types_1.hex)(link.logicalAddress) + " **");
684
691
  }
685
692
  });
693
+ this.attachOverkizListeners();
686
694
  (0, HB_1.hbSubscribe)(() => __awaiter(this, void 0, void 0, function* () {
687
695
  // Get all values for subscribed accessories (links)
688
696
  if (this.links.length && (yield this.checkToken())) {
@@ -724,37 +732,49 @@ function SmartSocketInit(message){
724
732
  (0, logger_1.debug)("smartapp", "Received DT value from unit: " + u.getDisplayName() + " -> " + u.value + " / " + u.status + " from unit");
725
733
  if (!this.links.length)
726
734
  return;
727
- if (!(yield this.checkToken()))
728
- return;
735
+ // only bother logging into the Homebridge API if we actually have an accessory-type link to serve
736
+ const needsToken = this.links.some(link => link.type === types_1.LinkType.kAccessory);
737
+ const tokenOK = needsToken ? yield this.checkToken() : true;
729
738
  for (const link of this.links) {
730
- if (u.isUnit(link.masterAddress, link.masterPort, link.logicalNodeAddress, link.logicalAddress)) {
731
- const hbValue = (0, HB_1.dt2hbvalue)(u, link.hbValue, link.min, link.max);
732
- if (typeof hbValue != "undefined") {
733
- try {
734
- if (hbValue === -1) {
739
+ if (!u.isUnit(link.masterAddress, link.masterPort, link.logicalNodeAddress, link.logicalAddress))
740
+ continue;
741
+ if (link.type === types_1.LinkType.kOverkiz) {
742
+ yield this.informOverkizLink(u, link);
743
+ continue;
744
+ }
745
+ if (!tokenOK)
746
+ continue;
747
+ const hbValue = (0, HB_1.dt2hbvalue)(u, link.hbValue, link.min, link.max);
748
+ if (typeof hbValue != "undefined") {
749
+ try {
750
+ if (hbValue === -1) {
751
+ if (link.nostop === "Y") {
752
+ (0, logger_1.debug)("smartapp", "-> Not sending simulated 'stop' for accessory: " + link.accName + " of Duotecno unit: " + u.getDisplayName() + " (disabled)");
753
+ }
754
+ else {
735
755
  (0, logger_1.log)("smartapp", "-> Virtual unit send 'stop' for accessory: " + link.accName + " of Duotecno unit: " + u.getDisplayName());
736
756
  const val = yield (0, HB_1.hbStop)(this.config.apiHost + ":" + this.config.apiPort, link.accId, link.accService, this.token);
737
757
  if (typeof val != "undefined")
738
758
  link.hbValue = val;
739
759
  }
740
- else if (hbValue !== link.hbValue) {
741
- (0, logger_1.log)("smartapp", "-> Setting HB value for accessory: " + link.accName + " to " + hbValue + " of Duotecno unit: " + u.getDisplayName() + " with value " + u.value);
742
- link.hbValue = hbValue;
743
- yield (0, HB_1.hbSetState)(this.config.apiHost + ":" + this.config.apiPort, link.accId, link.accService, hbValue, this.token);
744
- }
745
- else {
746
- (0, logger_1.debug)("smartapp", "-> No change for accessory: " + link.accName + " to " + u.value + " / " + u.status);
747
- }
748
760
  }
749
- catch (e) {
750
- if (e instanceof HB_1.HbAuthError) {
751
- (0, logger_1.err)("smartapp", "informLinks: Homebridge API token expired for accessory " + link.accName + " -> forcing re-login on next update");
752
- this.token = "";
753
- (0, config_1.setToken)("");
754
- }
755
- else {
756
- (0, logger_1.err)("smartapp", "informLinks: failed to set HB value for accessory " + link.accName + " -> " + e.toString());
757
- }
761
+ else if (hbValue !== link.hbValue) {
762
+ (0, logger_1.log)("smartapp", "-> Setting HB value for accessory: " + link.accName + " to " + hbValue + " of Duotecno unit: " + u.getDisplayName() + " with value " + u.value);
763
+ link.hbValue = hbValue;
764
+ yield (0, HB_1.hbSetState)(this.config.apiHost + ":" + this.config.apiPort, link.accId, link.accService, hbValue, this.token);
765
+ }
766
+ else {
767
+ (0, logger_1.debug)("smartapp", "-> No change for accessory: " + link.accName + " to " + u.value + " / " + u.status);
768
+ }
769
+ }
770
+ catch (e) {
771
+ if (e instanceof HB_1.HbAuthError) {
772
+ (0, logger_1.err)("smartapp", "informLinks: Homebridge API token expired for accessory " + link.accName + " -> forcing re-login on next update");
773
+ this.token = "";
774
+ (0, config_1.setToken)("");
775
+ }
776
+ else {
777
+ (0, logger_1.err)("smartapp", "informLinks: failed to set HB value for accessory " + link.accName + " -> " + e.toString());
758
778
  }
759
779
  }
760
780
  }
@@ -762,6 +782,186 @@ function SmartSocketInit(message){
762
782
  ;
763
783
  });
764
784
  }
785
+ // Duotecno -> Overkiz direction of a kOverkiz link. Mirrors the kAccessory
786
+ // branch above, but talks straight to the Overkiz device instead of going
787
+ // through the Homebridge REST API -- and unlike hbStop(), overkiz.stop()
788
+ // is a genuine 'stop' command, not a simulated one.
789
+ informOverkizLink(u, link) {
790
+ return __awaiter(this, void 0, void 0, function* () {
791
+ if (!this.power.overkiz)
792
+ return;
793
+ if (u.isUpDown()) {
794
+ yield this.informOverkizUpDown(u, link);
795
+ return;
796
+ }
797
+ // dimmer: dt2hbvalue reports the raw 0-100 intensity directly, no
798
+ // real/virtual distinction to worry about here (unlike up/down below).
799
+ const hbValue = (0, HB_1.dt2hbvalue)(u, link.hbValue, link.min, link.max);
800
+ if ((typeof hbValue === "undefined") || (hbValue === link.hbValue))
801
+ return;
802
+ try {
803
+ link.hbValue = hbValue;
804
+ (0, logger_1.log)("smartapp", "-> Setting Overkiz intensity for: " + link.accName + " to " + hbValue + " of Duotecno unit: " + u.getDisplayName());
805
+ yield this.power.overkiz.setIntensity(link.accId, hbValue);
806
+ }
807
+ catch (e) {
808
+ (0, logger_1.err)("smartapp", "informOverkizLink: failed for " + link.accName + " -> " + e.toString());
809
+ }
810
+ });
811
+ }
812
+ // A Duotecno up/down (kSwitchingMotor) unit reports status two different
813
+ // ways depending on how it's wired in the Duotecno programming:
814
+ // - a "virtual button" duoswitch macro event -> unit.value = 10 (stop) /
815
+ // 11 (up) / 12 (down), a one-shot pulse (this is what dt2hbvalue/HB.ts
816
+ // expects, and is what the existing Homebridge-accessory links rely on).
817
+ // - the real motor's own busy/idle status -> unit.value === unit.status,
818
+ // one of 0 (stopped), 1 (stopped/down), 2 (stopped/up), 3 (busy/down),
819
+ // 4 (busy/up) -- e.g. the "Garage" unit, which has no duoswitch macro
820
+ // wired to it and only ever reports these real values.
821
+ // Handle both: a real motor's busy<->idle transition is translated into
822
+ // up/down/stop the same way a virtual pulse would be.
823
+ informOverkizUpDown(u, link) {
824
+ return __awaiter(this, void 0, void 0, function* () {
825
+ if (!this.power.overkiz)
826
+ return;
827
+ const rawValue = Math.round(u.value);
828
+ try {
829
+ if ((rawValue === 10) || (rawValue === 11) || (rawValue === 12)) {
830
+ // virtual duoswitch pulse
831
+ if (rawValue === link.hbValue)
832
+ return;
833
+ link.hbValue = rawValue;
834
+ if (rawValue === 10) {
835
+ if (link.nostop === "Y") {
836
+ (0, logger_1.debug)("smartapp", "-> Not sending 'stop' to Overkiz device: " + link.accName + " (disabled)");
837
+ }
838
+ else {
839
+ (0, logger_1.log)("smartapp", "-> Sending 'stop' to Overkiz device: " + link.accName + " of Duotecno unit: " + u.getDisplayName());
840
+ yield this.power.overkiz.stop(link.accId);
841
+ }
842
+ }
843
+ else if (rawValue === 11) {
844
+ (0, logger_1.log)("smartapp", "-> Sending up/open to Overkiz device: " + link.accName + " of Duotecno unit: " + u.getDisplayName());
845
+ yield this.power.overkiz.up(link.accId);
846
+ }
847
+ else {
848
+ (0, logger_1.log)("smartapp", "-> Sending down/close to Overkiz device: " + link.accName + " of Duotecno unit: " + u.getDisplayName());
849
+ yield this.power.overkiz.down(link.accId);
850
+ }
851
+ return;
852
+ }
853
+ // real motor busy/idle status
854
+ const status = u.status;
855
+ const wasBusy = (link.hbValue === 3) || (link.hbValue === 4);
856
+ const isBusy = (status === 3) || (status === 4);
857
+ if (isBusy && (status !== link.hbValue)) {
858
+ link.hbValue = status;
859
+ if (status === 4) {
860
+ (0, logger_1.log)("smartapp", "-> Sending up/open to Overkiz device: " + link.accName + " of Duotecno unit: " + u.getDisplayName());
861
+ yield this.power.overkiz.up(link.accId);
862
+ }
863
+ else {
864
+ (0, logger_1.log)("smartapp", "-> Sending down/close to Overkiz device: " + link.accName + " of Duotecno unit: " + u.getDisplayName());
865
+ yield this.power.overkiz.down(link.accId);
866
+ }
867
+ }
868
+ else if (!isBusy && wasBusy) {
869
+ link.hbValue = status;
870
+ if (link.nostop === "Y") {
871
+ (0, logger_1.debug)("smartapp", "-> Not sending 'stop' to Overkiz device: " + link.accName + " (disabled)");
872
+ }
873
+ else {
874
+ (0, logger_1.log)("smartapp", "-> Sending 'stop' to Overkiz device: " + link.accName + " of Duotecno unit: " + u.getDisplayName());
875
+ yield this.power.overkiz.stop(link.accId);
876
+ }
877
+ }
878
+ else {
879
+ link.hbValue = status;
880
+ }
881
+ }
882
+ catch (e) {
883
+ (0, logger_1.err)("smartapp", "informOverkizUpDown: failed for " + link.accName + " -> " + e.toString());
884
+ }
885
+ });
886
+ }
887
+ attachOverkizListeners() {
888
+ if (!this.power.overkiz || (this.power.overkiz === this.attachedOverkiz))
889
+ return;
890
+ this.attachedOverkiz = this.power.overkiz;
891
+ this.power.overkiz.emitter.on("state", (deviceURL, unitState) => {
892
+ for (const link of this.links) {
893
+ if ((link.type !== types_1.LinkType.kOverkiz) || (link.accId !== deviceURL) || !link.unit)
894
+ continue;
895
+ if (unitState === link.dtValue)
896
+ continue; // no repeat
897
+ link.dtValue = unitState;
898
+ if ((unitState === types_1.UnitState.kOpen) || (unitState === types_1.UnitState.kOpening)) {
899
+ (0, logger_1.log)("smartapp", "-> Overkiz reports " + types_1.UnitState[unitState] + " for: " + link.accName + " -> setting Duotecno unit " + link.unit.getDisplayName() + " to open");
900
+ link.unit.setState(types_1.UnitMotorCmd.kOpen);
901
+ }
902
+ else if ((unitState === types_1.UnitState.kClosed) || (unitState === types_1.UnitState.kClosing)) {
903
+ (0, logger_1.log)("smartapp", "-> Overkiz reports " + types_1.UnitState[unitState] + " for: " + link.accName + " -> setting Duotecno unit " + link.unit.getDisplayName() + " to close");
904
+ link.unit.setState(types_1.UnitMotorCmd.kClose);
905
+ }
906
+ else {
907
+ // kStopped: Overkiz reports the device sitting still somewhere in
908
+ // the middle (e.g. stopped mid-way via the Somfy app/remote) --
909
+ // forward a genuine stop, same as the Duotecno -> Overkiz direction.
910
+ if (link.nostop === "Y") {
911
+ (0, logger_1.debug)("smartapp", "-> Not forwarding Overkiz 'stopped' for: " + link.accName + " (disabled)");
912
+ }
913
+ else {
914
+ (0, logger_1.log)("smartapp", "-> Overkiz reports stopped for: " + link.accName + " -> setting Duotecno unit " + link.unit.getDisplayName() + " to stop");
915
+ link.unit.setState(types_1.UnitMotorCmd.kStop);
916
+ }
917
+ }
918
+ }
919
+ });
920
+ this.power.overkiz.emitter.on("intensity", (deviceURL, intensity) => {
921
+ for (const link of this.links) {
922
+ if ((link.type !== types_1.LinkType.kOverkiz) || (link.accId !== deviceURL) || !link.unit)
923
+ continue;
924
+ const { dtValue, dtTarget } = (0, HB_1.hb2dtvalue)(link.unit, intensity);
925
+ if (dtValue !== link.dtValue) {
926
+ (0, logger_1.log)("smartapp", "-> Setting value of Duotecno unit: " + link.unit.getDisplayName() + " to " + dtTarget + " from Overkiz intensity " + intensity + " of: " + link.accName);
927
+ link.dtValue = dtValue;
928
+ link.unit.setState(dtTarget);
929
+ }
930
+ }
931
+ });
932
+ }
933
+ //////////////////////////////
934
+ // Overkiz (Somfy/TaHoma) //
935
+ //////////////////////////////
936
+ // Saves the Overkiz fields living on the Settings page and (re)connects.
937
+ // Only attempts a connection when credentials are actually present, so
938
+ // saving the rest of the Settings form doesn't produce a spurious
939
+ // "connection failed" for installs that don't use Overkiz at all.
940
+ saveOverkizSettings(context) {
941
+ var _a, _b;
942
+ return __awaiter(this, void 0, void 0, function* () {
943
+ const service = context.getParam({ name: "service", type: "string", default: "local" });
944
+ const user = context.getParam({ name: "user", type: "string", default: "" });
945
+ const submittedPassword = context.getParam({ name: "password", type: "string", default: "" });
946
+ // blank password field means "keep the current one"
947
+ const password = submittedPassword || ((_b = (_a = this.power.overkiz) === null || _a === void 0 ? void 0 : _a.config) === null || _b === void 0 ? void 0 : _b.password) || "";
948
+ const pollingPeriod = context.getParam({ name: "pollingPeriod", type: "integer", default: 10 });
949
+ if (!this.power.overkiz)
950
+ this.power.overkiz = new overkiz_1.Overkiz(this.system);
951
+ this.power.overkiz.config = types_1.Sanitizers.overkiz({ service, user, password, pollingPeriod });
952
+ this.power.overkiz.writeConfig();
953
+ if (!user || !password)
954
+ return "";
955
+ try {
956
+ yield this.power.overkiz.connect();
957
+ this.attachOverkizListeners();
958
+ return "Overkiz connected successfully -> " + this.power.overkiz.listDevices().length + " device(s) found.";
959
+ }
960
+ catch (e) {
961
+ return "Overkiz connection failed: " + (e.message || e.toString());
962
+ }
963
+ });
964
+ }
765
965
  checkToken() {
766
966
  return __awaiter(this, void 0, void 0, function* () {
767
967
  if (!this.token) {
@@ -788,6 +988,7 @@ function SmartSocketInit(message){
788
988
  return __awaiter(this, void 0, void 0, function* () {
789
989
  let inx = parseInt(context.id);
790
990
  const accessories = yield this.getAccessories();
991
+ const overkizDevices = this.power.overkiz ? this.power.overkiz.listDevices() : [];
791
992
  const units = this.system.allUsedUnits();
792
993
  let message;
793
994
  try {
@@ -795,11 +996,11 @@ function SmartSocketInit(message){
795
996
  this.links.push(Object.assign(Object.assign({}, types_1.kEmptyLink), { type: types_1.LinkType.kAccessory }));
796
997
  inx = this.links.length - 1;
797
998
  return this.ejs("linkDetail", context, { link: this.links[inx], id: inx,
798
- masters: this.system.masters, accessories, units });
999
+ masters: this.system.masters, accessories, overkizDevices, units });
799
1000
  }
800
1001
  else if (context.action === "edit") {
801
1002
  return this.ejs("linkDetail", context, { link: this.links[inx], id: inx,
802
- masters: this.system.masters, accessories, units });
1003
+ masters: this.system.masters, accessories, overkizDevices, units });
803
1004
  }
804
1005
  else if (context.action === "delete") {
805
1006
  this.deleteLink(inx);
@@ -819,20 +1020,29 @@ function SmartSocketInit(message){
819
1020
  });
820
1021
  }
821
1022
  scrapeLink(context) {
1023
+ var _a;
822
1024
  // don't take the name of the unit
823
1025
  const { masterAddress, masterPort, logicalAddress, logicalNodeAddress } = this.scrapeUnit(context, '');
824
- // for now only accessory links.
825
- const stype = context.getParam({ name: "type", type: "string", default: types_1.LinkType.kAccessory });
826
1026
  const name = context.getParam({ name: "name", type: "string", default: "--" });
827
1027
  // max & min values
828
1028
  const min = context.getParam({ name: "min", type: "integer", default: 0 });
829
1029
  const max = context.getParam({ name: "max", type: "integer", default: 100 });
830
- // uniqiue id for the accessory
831
- const accId = context.getParam({ name: "accId", type: "string", default: "" });
832
- const accName = (0, HB_1.hbName)(accId);
833
- const accService = (0, HB_1.hbService)(accId);
834
- return { accId, accName, accService, name, masterAddress, masterPort, logicalAddress, logicalNodeAddress, type: stype, min, max };
835
- ;
1030
+ // don't forward the simulated 'stop' of an up/down link to the accessory
1031
+ const nostop = context.getParam({ name: "nostop", type: "string", default: "N" });
1032
+ // the accessory dropdown offers both Homebridge accessories and Overkiz
1033
+ // devices in one list; an Overkiz option's value is prefixed so we know
1034
+ // which provider -- and hence Link type -- the picked id belongs to.
1035
+ const rawAccId = context.getParam({ name: "accId", type: "string", default: "" });
1036
+ const kOverkizPrefix = "overkiz:";
1037
+ if (rawAccId.startsWith(kOverkizPrefix)) {
1038
+ const accId = rawAccId.substring(kOverkizPrefix.length);
1039
+ const device = (_a = this.power.overkiz) === null || _a === void 0 ? void 0 : _a.listDevices().find(d => d.deviceURL === accId);
1040
+ return { accId, accName: (device === null || device === void 0 ? void 0 : device.label) || "", accService: device ? [device.uiClass] : [],
1041
+ name, masterAddress, masterPort, logicalAddress, logicalNodeAddress, type: types_1.LinkType.kOverkiz, min, max, nostop };
1042
+ }
1043
+ const accName = (0, HB_1.hbName)(rawAccId);
1044
+ const accService = (0, HB_1.hbService)(rawAccId);
1045
+ return { accId: rawAccId, accName, accService, name, masterAddress, masterPort, logicalAddress, logicalNodeAddress, type: types_1.LinkType.kAccessory, min, max, nostop };
836
1046
  }
837
1047
  updateLink(inx, link) {
838
1048
  if ((inx >= 0) && (inx < this.links.length)) {
@@ -61,18 +61,37 @@
61
61
  <select name="accId" id="accId">
62
62
  <option value="" <%= (link.accId == '') ? 'selected' : '' %>>Select an Accessory</option>
63
63
  <% for (let a of accessories) { %>
64
- <% if (a.manufacturer != manufacturer) {
65
- if (manufacturer) { %> </optgroup> <% }
66
- manufacturer = a.manufacturer; %>
64
+ <% const accManufacturer = (a.manufacturer.indexOf("Coppieters") !== -1) ? "Duotecno" : a.manufacturer; %>
65
+ <% if (accManufacturer != manufacturer) {
66
+ if (manufacturer) { %> </optgroup> <% }
67
+ manufacturer = accManufacturer; %>
67
68
  <optgroup label="<%= manufacturer %>">
68
69
  <% } %>
69
- <option value="<%= a.id %>" <%= (link.accId == a.id) ? 'selected' : '' %>><%= a.name %></option>
70
- <% }
70
+ <option value="<%= a.id %>" <%= ((link.type == 'accessory') && (link.accId == a.id)) ? 'selected' : '' %>><%= a.name %></option>
71
+ <% }
71
72
  if (manufacturer) { %> </optgroup> <% } %>
73
+ <% if (overkizDevices && overkizDevices.length) { %>
74
+ <optgroup label="Overkiz">
75
+ <% for (let d of overkizDevices) { %>
76
+ <option value="overkiz:<%= d.deviceURL %>" <%= ((link.type == 'overkiz') && (link.accId == d.deviceURL)) ? 'selected' : '' %>><%= d.label %> (<%= d.uiClass %>)</option>
77
+ <% } %>
78
+ </optgroup>
79
+ <% } %>
72
80
  </select>
73
- <label for="id">Linked HB accessory</label>
81
+ <label for="id">Linked accessory (Homebridge or Overkiz)</label>
82
+ </div>
83
+ </div>
84
+
85
+ <% if (link.unit && link.unit.isUpDown()) { %>
86
+ <div class="row">
87
+ <div class="switch input-field col s4">
88
+ <label>Send stop
89
+ <input id="nostop" name="nostop" <%= (link.nostop == 'Y') ? 'checked' : '' %> type="checkbox" value="Y">
90
+ <span class="lever"></span>Don't
91
+ </label>
74
92
  </div>
75
93
  </div>
94
+ <% } %>
76
95
 
77
96
  <div class="row">
78
97
  <div class="input-field col s4">
@@ -16,17 +16,25 @@
16
16
  <li <% if (request === "masters") { %>class="active"<% } %>><a href="<%= basePath %>/masters">Masters</a></li>
17
17
  <li <% if (request === "services") { %>class="active"<% } %>><a href="<%= basePath %>/services">Services</a></li>
18
18
  <li <% if (request === "proxy") { %>class="active"<% } %>><a href="<%= basePath %>/proxy">Proxy</a></li>
19
- <li <% if (request === "links") { %>class="active"<% } %>><a href="<%= basePath %>/links">Links</a></li>
20
- <li <% if (request === "devices") { %>class="active"<% } %>><a href="<%= basePath %>/devices">Devices</a></li>
21
- <% if (hasSmappee) { %><li <% if ((request === "power") && (type === "smappee")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=smappee">Smappee</a></li><% } %>
22
- <% if (hasP1) { %><li <% if ((request === "power") && (type === "p1")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=p1">P1</a></li><% } %>
23
- <% if (hasShelly) { %><li <% if ((request === "power") && (type === "shelly")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=shelly">Shelly</a></li><% } %>
24
- <li <% if (request === "switches") { %>class="active"<% } %>><a href="<%= basePath %>/switches">Switches</a></li>
19
+ <li <% if ((request === "links") || (request === "switches")) { %>class="active"<% } %>><a class="dropdown-trigger" href="#" data-target="linking-dropdown">Linking<i class="material-icons right">arrow_drop_down</i></a></li>
20
+ <li <% if ((request === "devices") || (request === "power")) { %>class="active"<% } %>><a class="dropdown-trigger" href="#" data-target="energy-dropdown">Energy<i class="material-icons right">arrow_drop_down</i></a></li>
25
21
  <li <% if (request === "settings") { %>class="active"<% } %>><a href="<%= basePath %>/settings">Settings</a></li>
26
22
  </ul>
27
23
  </div>
28
24
  </nav>
29
25
 
26
+ <ul id="linking-dropdown" class="dropdown-content">
27
+ <li <% if (request === "switches") { %>class="active"<% } %>><a href="<%= basePath %>/switches">Http Switches</a></li>
28
+ <li <% if (request === "links") { %>class="active"<% } %>><a href="<%= basePath %>/links">Accessory Links</a></li>
29
+ </ul>
30
+
31
+ <ul id="energy-dropdown" class="dropdown-content">
32
+ <li <% if (request === "devices") { %>class="active"<% } %>><a href="<%= basePath %>/devices">Websocket devices</a></li>
33
+ <% if (hasSmappee) { %><li <% if ((request === "power") && (type === "smappee")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=smappee">Smappee</a></li><% } %>
34
+ <% if (hasP1) { %><li <% if ((request === "power") && (type === "p1")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=p1">P1</a></li><% } %>
35
+ <% if (hasShelly) { %><li <% if ((request === "power") && (type === "shelly")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=shelly">Shelly</a></li><% } %>
36
+ </ul>
37
+
30
38
  <!-- Mobile Slide-out Menu -->
31
39
  <ul class="sidenav" id="mobile-menu">
32
40
  <li><div class="user-view">
@@ -35,16 +43,18 @@
35
43
  </div></li>
36
44
  <li <% if (request === "masters") { %>class="active"<% } %>><a href="<%= basePath %>/masters">Masters</a></li>
37
45
  <li <% if (request === "services") { %>class="active"<% } %>><a href="<%= basePath %>/services">Services</a></li>
46
+ <li <% if (request === "proxy") { %>class="active"<% } %>><a href="<%= basePath %>/proxy">Proxy</a></li>
38
47
  <li><div class="divider"></div></li>
39
- <li <% if (request === "devices") { %>class="active"<% } %>><a href="<%= basePath %>/devices">Devices</a></li>
40
- <li <% if (request === "links") { %>class="active"<% } %>><a href="<%= basePath %>/links">Links</a></li>
41
- <li <% if (request === "switches") { %>class="active"<% } %>><a href="<%= basePath %>/switches">Switches</a></li>
48
+ <li class="subheader">Linking</li>
49
+ <li <% if (request === "switches") { %>class="active"<% } %>><a href="<%= basePath %>/switches">Http Switches</a></li>
50
+ <li <% if (request === "links") { %>class="active"<% } %>><a href="<%= basePath %>/links">Accessory Links</a></li>
42
51
  <li><div class="divider"></div></li>
52
+ <li class="subheader">Energy</li>
53
+ <li <% if (request === "devices") { %>class="active"<% } %>><a href="<%= basePath %>/devices">Websocket devices</a></li>
43
54
  <% if (hasSmappee) { %><li <% if ((request === "power") && (type === "smappee")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=smappee">Smappee</a></li><% } %>
44
55
  <% if (hasP1) { %><li <% if ((request === "power") && (type === "p1")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=p1">P1 Meter</a></li><% } %>
45
56
  <% if (hasShelly) { %><li <% if ((request === "power") && (type === "shelly")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=shelly">Shelly PM</a></li><% } %>
46
57
  <li><div class="divider"></div></li>
47
- <li <% if (request === "proxy") { %>class="active"<% } %>><a href="<%= basePath %>/proxy">Proxy</a></li>
48
58
  <li <% if (request === "settings") { %>class="active"<% } %>><a href="<%= basePath %>/settings">Settings</a></li>
49
59
  </ul>
50
60
 
@@ -2,6 +2,7 @@
2
2
  <html>
3
3
  <head>
4
4
  <%- include('/views/head'); -%>
5
+ <style> .select-wrapper { max-width: 300px !important; } </style>
5
6
  </head>
6
7
 
7
8
  <body>
@@ -14,9 +15,9 @@
14
15
  <% if (typeof message !== 'undefined' && message) { %>
15
16
  <div class="row">
16
17
  <div class="col s12">
17
- <div class="card-panel <%= message.includes('successfully') ? 'green lighten-4' : 'orange lighten-4' %>">
18
- <span class="<%= message.includes('successfully') ? 'green-text text-darken-4' : 'orange-text text-darken-4' %>">
19
- <i class="material-icons left"><%= message.includes('successfully') ? 'check_circle' : 'info' %></i>
18
+ <div class="card-panel <%= message.includes('failed') ? 'orange lighten-4' : 'green lighten-4' %>">
19
+ <span class="<%= message.includes('failed') ? 'orange-text text-darken-4' : 'green-text text-darken-4' %>">
20
+ <i class="material-icons left"><%= message.includes('failed') ? 'info' : 'check_circle' %></i>
20
21
  <%= message %>
21
22
  </span>
22
23
  </div>
@@ -58,6 +59,71 @@
58
59
  </div>
59
60
  </div>
60
61
 
62
+ <style>
63
+ /* Materialize floats active labels to translateY(-14px), which sits too
64
+ close to the input value here -- push these up further. Scoped to this
65
+ section only, not a site-wide change. */
66
+ .overkiz-settings .input-field > label.active {
67
+ transform: translateY(-26px) scale(0.8) !important;
68
+ -webkit-transform: translateY(-26px) scale(0.8) !important;
69
+ }
70
+ </style>
71
+ <div class="overkiz-settings">
72
+ <h5>Overkiz (Somfy / TaHoma)</h5>
73
+ <div class="row">
74
+ <div class="input-field col s3">
75
+ <select name="service" id="service">
76
+ <option value="local" <%= (overkizConfig.service == 'local') ? 'selected' : '' %>>Local (developer mode)</option>
77
+ <option value="tahoma" <%= (overkizConfig.service == 'tahoma') ? 'selected' : '' %>>Cloud - TaHoma / Somfy Europe</option>
78
+ <option value="somfy_australia" <%= (overkizConfig.service == 'somfy_australia') ? 'selected' : '' %>>Cloud - Somfy Australia</option>
79
+ <option value="somfy_north_america" <%= (overkizConfig.service == 'somfy_north_america') ? 'selected' : '' %>>Cloud - Somfy North America</option>
80
+ <option value="connexoon" <%= (overkizConfig.service == 'connexoon') ? 'selected' : '' %>>Cloud - Connexoon</option>
81
+ <option value="cozytouch" <%= (overkizConfig.service == 'cozytouch') ? 'selected' : '' %>>Cloud - Cozytouch</option>
82
+ <option value="hi_kumo" <%= (overkizConfig.service == 'hi_kumo') ? 'selected' : '' %>>Cloud - Hi Kumo</option>
83
+ </select>
84
+ <label for="service">Service</label>
85
+ </div>
86
+
87
+ <div class="input-field col s3">
88
+ <input placeholder="1234-5678-9012 or IP" id="user" name="user" type="text" value="<%= overkizConfig.user %>">
89
+ <label for="user">User (local: gateway PIN or IP -- cloud: account username)</label>
90
+ </div>
91
+
92
+ <div class="input-field col s3">
93
+ <input placeholder="leave blank to keep current" id="password" name="password" type="password" value="">
94
+ <label for="password">Password (local: developer-mode token -- cloud: account password)</label>
95
+ </div>
96
+
97
+ <div class="input-field col s3">
98
+ <input placeholder="10" id="pollingPeriod" name="pollingPeriod" type="number" min="1" value="<%= overkizConfig.pollingPeriod %>">
99
+ <label for="pollingPeriod">Polling period (sec) -- how often we check for remote/app-triggered changes</label>
100
+ </div>
101
+ </div>
102
+
103
+ <div class="row">
104
+ <div class="col s12">
105
+ <b>Local developer mode</b>: activate it in the Somfy TaHoma app first
106
+ (<a href="https://developer.somfy.com/somfy-tahoma-developer-mode" target="_blank">developer.somfy.com</a>),
107
+ which gives you the gateway PIN and lets you generate a token.<br />
108
+ <b>Cloud mode</b> in Belgium choose: Cloud - TaHoma / Somfy Europe.<br />
109
+ Saving here (re)connects immediately.
110
+ </div>
111
+ </div>
112
+
113
+ <% if (overkizDevices && overkizDevices.length) { %>
114
+ <table>
115
+ <thead>
116
+ <tr><th>Label</th><th>Type</th><th>deviceURL</th></tr>
117
+ </thead>
118
+ <tbody>
119
+ <% for (let d of overkizDevices) { %>
120
+ <tr><td><%= d.label %></td><td><%= d.uiClass %> (<%= d.widget %>)</td><td><%= d.deviceURL %></td></tr>
121
+ <% } %>
122
+ </tbody>
123
+ </table>
124
+ <% } %>
125
+ </div>
126
+
61
127
  <div class="row">
62
128
  <div class="col s12">
63
129
  <button class="btn waves-effect waves-light" type="submit" name="action" value="save">
@@ -1 +0,0 @@
1
- {"sessionId":"29e28b48-3a89-4159-baf0-58e668c1fe0d","pid":61331,"procStart":"Wed Sep 16 12:58:32 2026","acquiredAt":1789563711220}