homebridge-smartsystem 7.1.26 → 7.2.1
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/udpConfig.js +90 -0
- package/package.json +1 -1
- package/server/smartapp.js +85 -6
- package/server/views/master-detail.ejs +10 -0
- package/server/views/nav.ejs +3 -3
|
@@ -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
|
|
4
|
+
"version": "7.2.1",
|
|
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/smartapp.js
CHANGED
|
@@ -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 || "
|
|
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
|
|
@@ -794,8 +795,22 @@ function SmartSocketInit(message){
|
|
|
794
795
|
yield this.informOverkizUpDown(u, link);
|
|
795
796
|
return;
|
|
796
797
|
}
|
|
797
|
-
// dimmer
|
|
798
|
-
//
|
|
798
|
+
// A dimmer (or, for a Screen/Awning target, a mood carrying a numeric
|
|
799
|
+
// macro payload -- the same "set to X%" trick previously used to park a
|
|
800
|
+
// screen/awning at a specific height) linked to a Screen/Awning sets its
|
|
801
|
+
// position; linked to a Light it sets intensity instead. Dispatch on the
|
|
802
|
+
// Overkiz device's own type, not just the Duotecno unit's type, since a
|
|
803
|
+
// dimmer unit is now also how a real Overkiz dimmer (Light) gets driven.
|
|
804
|
+
const targetUiClass = this.power.overkiz.uiClass(link.accId);
|
|
805
|
+
const isPositional = (targetUiClass === "Screen") || (targetUiClass === "Awning");
|
|
806
|
+
if (isPositional && (u.isDimmer() || u.isMood())) {
|
|
807
|
+
yield this.informOverkizPosition(u, link);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (!u.isDimmer())
|
|
811
|
+
return;
|
|
812
|
+
// dt2hbvalue reports the raw 0-100 intensity directly, no real/virtual
|
|
813
|
+
// distinction to worry about here (unlike up/down above).
|
|
799
814
|
const hbValue = (0, HB_1.dt2hbvalue)(u, link.hbValue, link.min, link.max);
|
|
800
815
|
if ((typeof hbValue === "undefined") || (hbValue === link.hbValue))
|
|
801
816
|
return;
|
|
@@ -809,6 +824,32 @@ function SmartSocketInit(message){
|
|
|
809
824
|
}
|
|
810
825
|
});
|
|
811
826
|
}
|
|
827
|
+
// Sets a Screen/Awning to a specific position (0-100) from a dimmer's raw
|
|
828
|
+
// value, or from a mood unit's value when Duotecno programming sends it a
|
|
829
|
+
// numeric macro payload (the same mechanism dimmers use -- a mood isn't
|
|
830
|
+
// normally more than an on/off trigger, but if its macro carries a value
|
|
831
|
+
// it ends up in unit.value exactly like a dimmer's does). Our scale is
|
|
832
|
+
// 0=off/closed, 100=on/fully open (matching how dimmer links always
|
|
833
|
+
// worked here); Overkiz's closure/deployment scale is the opposite
|
|
834
|
+
// (0=open, 100=closed), so invert on the way out.
|
|
835
|
+
informOverkizPosition(u, link) {
|
|
836
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
837
|
+
if (!this.power.overkiz)
|
|
838
|
+
return;
|
|
839
|
+
const raw = (typeof u.value === "boolean") ? (u.value ? 100 : 0) : Math.round(u.value);
|
|
840
|
+
const position = Math.max(0, Math.min(100, raw));
|
|
841
|
+
if (position === link.hbValue)
|
|
842
|
+
return;
|
|
843
|
+
try {
|
|
844
|
+
link.hbValue = position;
|
|
845
|
+
(0, logger_1.log)("smartapp", "-> Setting Overkiz position for: " + link.accName + " to " + position + "% of Duotecno unit: " + u.getDisplayName());
|
|
846
|
+
yield this.power.overkiz.setClosure(link.accId, 100 - position);
|
|
847
|
+
}
|
|
848
|
+
catch (e) {
|
|
849
|
+
(0, logger_1.err)("smartapp", "informOverkizPosition: failed for " + link.accName + " -> " + e.toString());
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
}
|
|
812
853
|
// A Duotecno up/down (kSwitchingMotor) unit reports status two different
|
|
813
854
|
// ways depending on how it's wired in the Duotecno programming:
|
|
814
855
|
// - a "virtual button" duoswitch macro event -> unit.value = 10 (stop) /
|
|
@@ -888,10 +929,27 @@ function SmartSocketInit(message){
|
|
|
888
929
|
if (!this.power.overkiz || (this.power.overkiz === this.attachedOverkiz))
|
|
889
930
|
return;
|
|
890
931
|
this.attachedOverkiz = this.power.overkiz;
|
|
891
|
-
this.power.overkiz.emitter.on("state", (deviceURL, unitState) => {
|
|
932
|
+
this.power.overkiz.emitter.on("state", (deviceURL, unitState, closure) => {
|
|
892
933
|
for (const link of this.links) {
|
|
893
934
|
if ((link.type !== types_1.LinkType.kOverkiz) || (link.accId !== deviceURL) || !link.unit)
|
|
894
935
|
continue;
|
|
936
|
+
if (link.unit.isDimmer()) {
|
|
937
|
+
// position-sourced link: reflect Overkiz's position back as a
|
|
938
|
+
// dimmer value. Overkiz's closure/deployment scale is the opposite
|
|
939
|
+
// of ours (0=open/100=closed vs our 0=off/100=on) -- invert back.
|
|
940
|
+
const position = Math.max(0, Math.min(100, Math.round(100 - closure)));
|
|
941
|
+
if (position === link.dtValue)
|
|
942
|
+
continue;
|
|
943
|
+
link.dtValue = position;
|
|
944
|
+
(0, logger_1.log)("smartapp", "-> Setting value of Duotecno unit: " + link.unit.getDisplayName() + " to " + position + " from Overkiz position " + closure + " of: " + link.accName);
|
|
945
|
+
link.unit.setState(position);
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
// a mood can only be triggered, not set to an arbitrary value, so a
|
|
949
|
+
// mood-sourced position link is Duotecno -> Overkiz only; nothing to
|
|
950
|
+
// reflect back here. Only an up/down unit gets the state below.
|
|
951
|
+
if (!link.unit.isUpDown())
|
|
952
|
+
continue;
|
|
895
953
|
if (unitState === link.dtValue)
|
|
896
954
|
continue; // no repeat
|
|
897
955
|
link.dtValue = unitState;
|
|
@@ -1674,7 +1732,7 @@ function SmartSocketInit(message){
|
|
|
1674
1732
|
let message;
|
|
1675
1733
|
try {
|
|
1676
1734
|
if (context.action === "new") {
|
|
1677
|
-
return this.ejs("masterDetail", context, { config: types_1.Sanitizers.masterConfig(null), registers: [], nodes: [] });
|
|
1735
|
+
return this.ejs("masterDetail", context, { config: types_1.Sanitizers.masterConfig(null), registers: [], nodes: [], loggedIn: false });
|
|
1678
1736
|
}
|
|
1679
1737
|
else if (context.action === "list") {
|
|
1680
1738
|
return this.ejs("unitList", context, { masters: this.system.masters });
|
|
@@ -1686,7 +1744,7 @@ function SmartSocketInit(message){
|
|
|
1686
1744
|
context.getMaster("id");
|
|
1687
1745
|
const master = this.system.findMaster(context["masterAddress"], context["masterPort"]);
|
|
1688
1746
|
if (master)
|
|
1689
|
-
return this.ejs("masterDetail", context, { nodes: master.nodes, config: master.getConfig(), registers: master.registers });
|
|
1747
|
+
return this.ejs("masterDetail", context, { nodes: master.nodes, config: master.getConfig(), registers: master.registers, loggedIn: master.isLoggedIn });
|
|
1690
1748
|
else
|
|
1691
1749
|
message = "Error: Master not found";
|
|
1692
1750
|
}
|
|
@@ -1713,6 +1771,27 @@ function SmartSocketInit(message){
|
|
|
1713
1771
|
else
|
|
1714
1772
|
message = "Error: Master not found";
|
|
1715
1773
|
}
|
|
1774
|
+
else if (context.action === "changepw") {
|
|
1775
|
+
const master = this.system.findMaster(context.getParam(kAddressOld), context.getParam(kPortOld));
|
|
1776
|
+
if (!master)
|
|
1777
|
+
message = "Error: Master not found";
|
|
1778
|
+
else if (!master.isLoggedIn)
|
|
1779
|
+
message = "Error: not logged in to this master -> refused to change its password";
|
|
1780
|
+
else {
|
|
1781
|
+
const address = context.getParam(kAddress);
|
|
1782
|
+
const port = context.getParam(kPort);
|
|
1783
|
+
const password = context.getParam(kPassword);
|
|
1784
|
+
if (yield (0, udpConfig_1.setHomeServerCfg)(address, password, port)) {
|
|
1785
|
+
const updated = yield this.system.addMaster(context.getParam(kAddressOld), context.getParam(kPortOld), { address, port, password, name: context.getParam(kName),
|
|
1786
|
+
active: context.getParam(kActive) != "N", nodenames: {} });
|
|
1787
|
+
this.updateNodes(updated, context.params.nodes || "", context.params);
|
|
1788
|
+
this.system.writeConfig();
|
|
1789
|
+
message = "Password changed on master and saved";
|
|
1790
|
+
}
|
|
1791
|
+
else
|
|
1792
|
+
message = "Error: master refused the new password";
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1716
1795
|
}
|
|
1717
1796
|
catch (e) {
|
|
1718
1797
|
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
|
package/server/views/nav.ejs
CHANGED
|
@@ -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
|
|
50
|
-
<li <% if (request === "links") { %>class="active"<% } %>><a href="<%= basePath %>/links">Accessory
|
|
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">
|
|
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><% } %>
|