homebridge-smartsystem 7.1.26 → 7.2.2

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/duotecno/types.js CHANGED
@@ -20,7 +20,9 @@ exports.kEmptyProxy = {
20
20
  cloudPort: 5097,
21
21
  masterAddress: "",
22
22
  masterPort: 5001,
23
+ masterPortEnabled: false,
23
24
  masterConfigPort: 8080,
25
+ masterConfigPortEnabled: false,
24
26
  installationId: "",
25
27
  nodeNumber: 0xff,
26
28
  httpEnabled: false,
@@ -441,7 +443,9 @@ exports.Sanitizers = {
441
443
  c.cloudPort = (config === null || config === void 0 ? void 0 : config.cloudPort) || exports.kEmptyProxy.cloudPort;
442
444
  c.masterAddress = (config === null || config === void 0 ? void 0 : config.masterAddress) || ((_a = system === null || system === void 0 ? void 0 : system.cmasters[0]) === null || _a === void 0 ? void 0 : _a.address) || "";
443
445
  c.masterPort = (config === null || config === void 0 ? void 0 : config.masterPort) || ((_b = system === null || system === void 0 ? void 0 : system.cmasters[0]) === null || _b === void 0 ? void 0 : _b.port) || 5001;
446
+ c.masterPortEnabled = !!(config === null || config === void 0 ? void 0 : config.masterPortEnabled);
444
447
  c.masterConfigPort = (config === null || config === void 0 ? void 0 : config.masterConfigPort) || exports.kEmptyProxy.masterConfigPort;
448
+ c.masterConfigPortEnabled = !!(config === null || config === void 0 ? void 0 : config.masterConfigPortEnabled);
445
449
  c.installationId = (config === null || config === void 0 ? void 0 : config.installationId) || exports.kEmptyProxy.installationId;
446
450
  c.nodeNumber = (typeof (config === null || config === void 0 ? void 0 : config.nodeNumber) === "number") ? config.nodeNumber : exports.kEmptyProxy.nodeNumber;
447
451
  c.httpEnabled = !!(config === null || config === void 0 ? void 0 : config.httpEnabled);
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.setHomeServerCfg = exports.UDP_CONFIG_PORT = void 0;
13
+ // Minimal client for Duotecno's "UDP config tool" protocol -- just enough to
14
+ // push a new home-server (master) password/port to a master over UDP.
15
+ //
16
+ // Wire format reverse-engineered from the real `dtudpconfigLIB` source (see
17
+ // the Duotecno Designer project's src/services/udpConfig/protocol.ts for the
18
+ // full writeup and provenance notes). Devices listen on UDP port 8002.
19
+ // ASCII framing: `[b0,b1,...]` + LF, every byte its decimal string,
20
+ // comma-separated. Strings are length-prefixed (one length byte, then that
21
+ // many byte-per-char, no null terminator). Multi-byte ints are MSB-first.
22
+ const dgram = require("dgram");
23
+ exports.UDP_CONFIG_PORT = 8002;
24
+ const CMD_FC_APPCONFIG = 189;
25
+ const CMD_EV_APPCONFIG = 15;
26
+ const ATTR_HOMESRVCFG_SET = 2;
27
+ // Always written as "closed + open protocol enabled", matching the legacy
28
+ // config tool -- it's a read-only display value, not a user-editable input.
29
+ const HOMESRVCFG_PROTOCOL_FLAGS = 0x03;
30
+ function encodePacket(bytes) {
31
+ return Buffer.from(`[${bytes.join(",")}]\n`, "ascii");
32
+ }
33
+ function decodePacket(buf) {
34
+ const str = buf.toString("ascii").trim();
35
+ const match = /^\[([^\]]*)\]/.exec(str);
36
+ if (!match || !match[1])
37
+ return [];
38
+ return match[1].split(",").map((s) => parseInt(s.trim(), 10));
39
+ }
40
+ function stringToBytes(s) {
41
+ return [s.length, ...Array.from(s).map((c) => c.charCodeAt(0))];
42
+ }
43
+ function u16ToBytes(v) {
44
+ return [(v >> 8) & 0xff, v & 0xff];
45
+ }
46
+ /** Sends one unicast packet to ip:8002 and waits for a matching reply, retrying on timeout (UDP can drop packets). */
47
+ function request(ip, packetBytes, expectCmd, expectMethod, timeoutMs = 1500, retries = 2) {
48
+ return new Promise((resolve, reject) => {
49
+ const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
50
+ let attemptsLeft = retries;
51
+ let timer;
52
+ const cleanup = () => {
53
+ clearTimeout(timer);
54
+ socket.close();
55
+ };
56
+ socket.on("error", (err) => {
57
+ cleanup();
58
+ reject(err);
59
+ });
60
+ socket.on("message", (msg) => {
61
+ const data = decodePacket(msg);
62
+ if (data[0] === expectCmd && data[1] === expectMethod) {
63
+ cleanup();
64
+ resolve(data);
65
+ }
66
+ });
67
+ function send() {
68
+ socket.send(encodePacket(packetBytes), exports.UDP_CONFIG_PORT, ip);
69
+ timer = setTimeout(() => {
70
+ if (attemptsLeft-- > 0)
71
+ send();
72
+ else {
73
+ cleanup();
74
+ reject(new Error(`No UDP config reply from ${ip} (cmd ${expectCmd}/${expectMethod})`));
75
+ }
76
+ }, timeoutMs);
77
+ }
78
+ socket.bind(0, send);
79
+ });
80
+ }
81
+ /** Pushes a new home-server password/TCP-port to a master over the UDP config protocol (port 8002). */
82
+ function setHomeServerCfg(ip, password, ipPort, timeoutMs) {
83
+ return __awaiter(this, void 0, void 0, function* () {
84
+ const packet = [CMD_FC_APPCONFIG, ATTR_HOMESRVCFG_SET, ...stringToBytes(password), ...u16ToBytes(ipPort), ...u16ToBytes(HOMESRVCFG_PROTOCOL_FLAGS)];
85
+ const reply = yield request(ip, packet, CMD_EV_APPCONFIG, ATTR_HOMESRVCFG_SET, timeoutMs);
86
+ return reply[2] === 1;
87
+ });
88
+ }
89
+ exports.setHomeServerCfg = setHomeServerCfg;
90
+ //# sourceMappingURL=udpConfig.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-smartsystem",
3
3
  "displayname": "Duotecno Bridge",
4
- "version": "7.1.26",
4
+ "version": "7.2.2",
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/proxy.js CHANGED
@@ -90,7 +90,9 @@ function setProxyConfig(c) {
90
90
  config.cloudPort = c.cloudPort || types_1.kEmptyProxy.cloudPort;
91
91
  config.masterAddress = c.masterAddress || types_1.kEmptyProxy.masterAddress;
92
92
  config.masterPort = c.masterPort || types_1.kEmptyProxy.masterPort;
93
+ config.masterPortEnabled = !!c.masterPortEnabled;
93
94
  config.masterConfigPort = c.masterConfigPort || types_1.kEmptyProxy.masterConfigPort;
95
+ config.masterConfigPortEnabled = !!c.masterConfigPortEnabled;
94
96
  config.installationId = c.installationId || types_1.kEmptyProxy.installationId;
95
97
  config.nodeNumber = (typeof c.nodeNumber === "number") ? c.nodeNumber : types_1.kEmptyProxy.nodeNumber;
96
98
  config.httpEnabled = !!c.httpEnabled;
@@ -194,21 +196,25 @@ function cleanStart(restart = false) {
194
196
  if (restart) {
195
197
  log(`[PROXY] → [CLOUD] Restarting proxy...`);
196
198
  let connectionsStarted = 0;
197
- // Start connection for the master device's raw protocol port if an installation ID is configured.
198
- // Masters always register under the fixed master node number (kMasterNodeNumber), not the
199
- // gateway's own (configurable) nodeNumber.
200
- if (config.installationId && config.masterAddress) {
199
+ // Start connection for the master device's raw protocol port if an installation ID is configured
200
+ // and this tunnel is explicitly enabled (off by default). Masters always register under the
201
+ // fixed master node number (kMasterNodeNumber), not the gateway's own (configurable) nodeNumber.
202
+ if (config.installationId && config.masterAddress && config.masterPortEnabled) {
201
203
  const masterId = makeDeviceId(config, exports.kMasterNodeNumber, "tcp", config.masterPort);
202
204
  log(`[PROXY] Starting proxy for master device: ${masterId}`);
203
205
  makeNewCloudConnection(config.masterAddress, config.masterPort, config.cloudServer, config.cloudPort, masterId);
204
206
  connectionsStarted++;
205
207
  }
208
+ else if (config.installationId && config.masterAddress) {
209
+ log(`[PROXY] → [CLOUD] Master TCP tunnel disabled, skipping master connection.`);
210
+ }
206
211
  else if (config.installationId) {
207
212
  log(`[PROXY] → [CLOUD] Installation ID configured but no masterAddress, skipping master connection.`);
208
213
  }
209
214
  // Start a proxy for the master's Config API port -- this is an HTTP API, so it's tagged
210
215
  // "http" rather than "tcp" even though the tunnel itself just relays raw bytes either way.
211
- if (config.installationId && config.masterAddress && config.masterConfigPort) {
216
+ // Also off by default, only started when explicitly enabled.
217
+ if (config.installationId && config.masterAddress && config.masterConfigPort && config.masterConfigPortEnabled) {
212
218
  const configId = makeDeviceId(config, exports.kMasterNodeNumber, "http", config.masterConfigPort);
213
219
  log(`[PROXY] Starting proxy for Config API: ${configId} -> ${config.masterAddress}:${config.masterConfigPort}`);
214
220
  // All connections use the same cloud port for WebSocket multiplexing
@@ -27,6 +27,7 @@ const somfy = require("./somfy");
27
27
  const fs_1 = require("fs");
28
28
  const overkiz_1 = require("./overkiz");
29
29
  const config_1 = require("../duotecno/config");
30
+ const udpConfig_1 = require("../duotecno/udpConfig");
30
31
  const HB_1 = require("./HB");
31
32
  const HA_API_1 = require("./HA-API");
32
33
  const proxy_1 = require("./proxy");
@@ -74,7 +75,7 @@ class SmartApp extends webapp_1.WebApp {
74
75
  // get some configurated params
75
76
  const configuredPort = this.config.port || 80;
76
77
  this.password = platform.config.password || this.config.password || "";
77
- this.user = platform.config.user || this.config.user || "pi";
78
+ this.user = platform.config.user || this.config.user || "duotecno";
78
79
  // Set up multi-port listening if port is not 80
79
80
  // Note: Port 80 requires root/sudo on Unix-like systems
80
81
  // For development on macOS/Linux without sudo, use only configured port
@@ -391,7 +392,9 @@ function SmartSocketInit(message){
391
392
  config.cloudPort = context.getParam({ name: "cloudPort", type: "integer" });
392
393
  config.masterAddress = context.getParam({ name: "masterAddress", type: "string" });
393
394
  config.masterPort = context.getParam({ name: "masterPort", type: "integer" });
395
+ config.masterPortEnabled = !!context.getParam({ name: "masterPortEnabled", type: "string" });
394
396
  config.masterConfigPort = context.getParam({ name: "masterConfigPort", type: "integer" });
397
+ config.masterConfigPortEnabled = !!context.getParam({ name: "masterConfigPortEnabled", type: "string" });
395
398
  config.installationId = context.getParam({ name: "installationId", type: "string" });
396
399
  config.nodeNumber = context.getParam({ name: "nodeNumber", type: "string" })
397
400
  ? parseInt(context.getParam({ name: "nodeNumber", type: "string" }), 16) : proxy_1.kEmptyProxy.nodeNumber;
@@ -794,8 +797,22 @@ function SmartSocketInit(message){
794
797
  yield this.informOverkizUpDown(u, link);
795
798
  return;
796
799
  }
797
- // dimmer: dt2hbvalue reports the raw 0-100 intensity directly, no
798
- // real/virtual distinction to worry about here (unlike up/down below).
800
+ // A dimmer (or, for a Screen/Awning target, a mood carrying a numeric
801
+ // macro payload -- the same "set to X%" trick previously used to park a
802
+ // screen/awning at a specific height) linked to a Screen/Awning sets its
803
+ // position; linked to a Light it sets intensity instead. Dispatch on the
804
+ // Overkiz device's own type, not just the Duotecno unit's type, since a
805
+ // dimmer unit is now also how a real Overkiz dimmer (Light) gets driven.
806
+ const targetUiClass = this.power.overkiz.uiClass(link.accId);
807
+ const isPositional = (targetUiClass === "Screen") || (targetUiClass === "Awning");
808
+ if (isPositional && (u.isDimmer() || u.isMood())) {
809
+ yield this.informOverkizPosition(u, link);
810
+ return;
811
+ }
812
+ if (!u.isDimmer())
813
+ return;
814
+ // dt2hbvalue reports the raw 0-100 intensity directly, no real/virtual
815
+ // distinction to worry about here (unlike up/down above).
799
816
  const hbValue = (0, HB_1.dt2hbvalue)(u, link.hbValue, link.min, link.max);
800
817
  if ((typeof hbValue === "undefined") || (hbValue === link.hbValue))
801
818
  return;
@@ -809,6 +826,32 @@ function SmartSocketInit(message){
809
826
  }
810
827
  });
811
828
  }
829
+ // Sets a Screen/Awning to a specific position (0-100) from a dimmer's raw
830
+ // value, or from a mood unit's value when Duotecno programming sends it a
831
+ // numeric macro payload (the same mechanism dimmers use -- a mood isn't
832
+ // normally more than an on/off trigger, but if its macro carries a value
833
+ // it ends up in unit.value exactly like a dimmer's does). Our scale is
834
+ // 0=off/closed, 100=on/fully open (matching how dimmer links always
835
+ // worked here); Overkiz's closure/deployment scale is the opposite
836
+ // (0=open, 100=closed), so invert on the way out.
837
+ informOverkizPosition(u, link) {
838
+ return __awaiter(this, void 0, void 0, function* () {
839
+ if (!this.power.overkiz)
840
+ return;
841
+ const raw = (typeof u.value === "boolean") ? (u.value ? 100 : 0) : Math.round(u.value);
842
+ const position = Math.max(0, Math.min(100, raw));
843
+ if (position === link.hbValue)
844
+ return;
845
+ try {
846
+ link.hbValue = position;
847
+ (0, logger_1.log)("smartapp", "-> Setting Overkiz position for: " + link.accName + " to " + position + "% of Duotecno unit: " + u.getDisplayName());
848
+ yield this.power.overkiz.setClosure(link.accId, 100 - position);
849
+ }
850
+ catch (e) {
851
+ (0, logger_1.err)("smartapp", "informOverkizPosition: failed for " + link.accName + " -> " + e.toString());
852
+ }
853
+ });
854
+ }
812
855
  // A Duotecno up/down (kSwitchingMotor) unit reports status two different
813
856
  // ways depending on how it's wired in the Duotecno programming:
814
857
  // - a "virtual button" duoswitch macro event -> unit.value = 10 (stop) /
@@ -888,10 +931,27 @@ function SmartSocketInit(message){
888
931
  if (!this.power.overkiz || (this.power.overkiz === this.attachedOverkiz))
889
932
  return;
890
933
  this.attachedOverkiz = this.power.overkiz;
891
- this.power.overkiz.emitter.on("state", (deviceURL, unitState) => {
934
+ this.power.overkiz.emitter.on("state", (deviceURL, unitState, closure) => {
892
935
  for (const link of this.links) {
893
936
  if ((link.type !== types_1.LinkType.kOverkiz) || (link.accId !== deviceURL) || !link.unit)
894
937
  continue;
938
+ if (link.unit.isDimmer()) {
939
+ // position-sourced link: reflect Overkiz's position back as a
940
+ // dimmer value. Overkiz's closure/deployment scale is the opposite
941
+ // of ours (0=open/100=closed vs our 0=off/100=on) -- invert back.
942
+ const position = Math.max(0, Math.min(100, Math.round(100 - closure)));
943
+ if (position === link.dtValue)
944
+ continue;
945
+ link.dtValue = position;
946
+ (0, logger_1.log)("smartapp", "-> Setting value of Duotecno unit: " + link.unit.getDisplayName() + " to " + position + " from Overkiz position " + closure + " of: " + link.accName);
947
+ link.unit.setState(position);
948
+ continue;
949
+ }
950
+ // a mood can only be triggered, not set to an arbitrary value, so a
951
+ // mood-sourced position link is Duotecno -> Overkiz only; nothing to
952
+ // reflect back here. Only an up/down unit gets the state below.
953
+ if (!link.unit.isUpDown())
954
+ continue;
895
955
  if (unitState === link.dtValue)
896
956
  continue; // no repeat
897
957
  link.dtValue = unitState;
@@ -1674,7 +1734,7 @@ function SmartSocketInit(message){
1674
1734
  let message;
1675
1735
  try {
1676
1736
  if (context.action === "new") {
1677
- return this.ejs("masterDetail", context, { config: types_1.Sanitizers.masterConfig(null), registers: [], nodes: [] });
1737
+ return this.ejs("masterDetail", context, { config: types_1.Sanitizers.masterConfig(null), registers: [], nodes: [], loggedIn: false });
1678
1738
  }
1679
1739
  else if (context.action === "list") {
1680
1740
  return this.ejs("unitList", context, { masters: this.system.masters });
@@ -1686,7 +1746,7 @@ function SmartSocketInit(message){
1686
1746
  context.getMaster("id");
1687
1747
  const master = this.system.findMaster(context["masterAddress"], context["masterPort"]);
1688
1748
  if (master)
1689
- return this.ejs("masterDetail", context, { nodes: master.nodes, config: master.getConfig(), registers: master.registers });
1749
+ return this.ejs("masterDetail", context, { nodes: master.nodes, config: master.getConfig(), registers: master.registers, loggedIn: master.isLoggedIn });
1690
1750
  else
1691
1751
  message = "Error: Master not found";
1692
1752
  }
@@ -1713,6 +1773,27 @@ function SmartSocketInit(message){
1713
1773
  else
1714
1774
  message = "Error: Master not found";
1715
1775
  }
1776
+ else if (context.action === "changepw") {
1777
+ const master = this.system.findMaster(context.getParam(kAddressOld), context.getParam(kPortOld));
1778
+ if (!master)
1779
+ message = "Error: Master not found";
1780
+ else if (!master.isLoggedIn)
1781
+ message = "Error: not logged in to this master -> refused to change its password";
1782
+ else {
1783
+ const address = context.getParam(kAddress);
1784
+ const port = context.getParam(kPort);
1785
+ const password = context.getParam(kPassword);
1786
+ if (yield (0, udpConfig_1.setHomeServerCfg)(address, password, port)) {
1787
+ const updated = yield this.system.addMaster(context.getParam(kAddressOld), context.getParam(kPortOld), { address, port, password, name: context.getParam(kName),
1788
+ active: context.getParam(kActive) != "N", nodenames: {} });
1789
+ this.updateNodes(updated, context.params.nodes || "", context.params);
1790
+ this.system.writeConfig();
1791
+ message = "Password changed on master and saved";
1792
+ }
1793
+ else
1794
+ message = "Error: master refused the new password";
1795
+ }
1796
+ }
1716
1797
  }
1717
1798
  catch (e) {
1718
1799
  message = e.toString();
@@ -45,6 +45,16 @@
45
45
  <label for="password">Password</label>
46
46
  </div>
47
47
  </div>
48
+ <% if (typeof nodes !== "undefined") { %>
49
+ <div class="row">
50
+ <div class="col s12">
51
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="changepw" <%= loggedIn ? '' : 'disabled' %>>Change Password on Master</button>
52
+ <% if (!loggedIn) { %>
53
+ <span class="grey-text"><small>Only available while logged in to this master.</small></span>
54
+ <% } %>
55
+ </div>
56
+ </div>
57
+ <% } %>
48
58
  <div class="row">
49
59
  <div class="col s6">
50
60
  Active
@@ -46,11 +46,11 @@
46
46
  <li <% if (request === "proxy") { %>class="active"<% } %>><a href="<%= basePath %>/proxy">Proxy</a></li>
47
47
  <li><div class="divider"></div></li>
48
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>
49
+ <li <% if (request === "switches") { %>class="active"<% } %>><a href="<%= basePath %>/switches">Http</a></li>
50
+ <li <% if (request === "links") { %>class="active"<% } %>><a href="<%= basePath %>/links">Accessory</a></li>
51
51
  <li><div class="divider"></div></li>
52
52
  <li class="subheader">Energy</li>
53
- <li <% if (request === "devices") { %>class="active"<% } %>><a href="<%= basePath %>/devices">Websocket devices</a></li>
53
+ <li <% if (request === "devices") { %>class="active"<% } %>><a href="<%= basePath %>/devices">Julix</a></li>
54
54
  <% if (hasSmappee) { %><li <% if ((request === "power") && (type === "smappee")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=smappee">Smappee</a></li><% } %>
55
55
  <% if (hasP1) { %><li <% if ((request === "power") && (type === "p1")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=p1">P1 Meter</a></li><% } %>
56
56
  <% if (hasShelly) { %><li <% if ((request === "power") && (type === "shelly")) { %>class="active"<% } %>><a href="<%= basePath %>/power?type=shelly">Shelly PM</a></li><% } %>
@@ -144,7 +144,8 @@
144
144
  <div class="col s6">
145
145
  <p>
146
146
  <label>
147
- <input type="checkbox" class="filled-in" checked disabled />
147
+ <input type="checkbox" class="filled-in" id="masterPortEnabled" name="masterPortEnabled" value="Y"
148
+ <%= config.masterPortEnabled ? "checked" : "" %> />
148
149
  <span><strong>Websocket</strong></span>
149
150
  </label>
150
151
  </p>
@@ -157,7 +158,8 @@
157
158
  <div class="col s6">
158
159
  <p>
159
160
  <label>
160
- <input type="checkbox" class="filled-in" checked disabled />
161
+ <input type="checkbox" class="filled-in" id="masterConfigPortEnabled" name="masterConfigPortEnabled" value="Y"
162
+ <%= config.masterConfigPortEnabled ? "checked" : "" %> />
161
163
  <span><strong>Config port</strong></span>
162
164
  </label>
163
165
  </p>