homebridge-smartsystem 7.1.21 → 7.1.23
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/accessories/accessory.js +2 -2
- package/accessories/dimmer.js +2 -1
- package/accessories/temperature.js +2 -1
- package/duotecno/types.js +5 -1
- package/package.json +2 -2
- package/server/platform.js +1 -1
- package/server/proxy.js +199 -68
- package/server/smartapp.js +72 -5
- package/server/views/assets/custom.css +63 -0
- package/server/views/assets/material-icons.woff2 +0 -0
- package/server/views/device-details.ejs +1 -1
- package/server/views/device-list.ejs +5 -5
- package/server/views/footer.ejs +1 -1
- package/server/views/head.ejs +5 -4
- package/server/views/homekit.ejs +2 -2
- package/server/views/link-details.ejs +1 -1
- package/server/views/link-list.ejs +5 -5
- package/server/views/login.ejs +3 -3
- package/server/views/master-detail.ejs +3 -3
- package/server/views/master-list.ejs +6 -6
- package/server/views/nav.ejs +22 -22
- package/server/views/node-details.ejs +4 -4
- package/server/views/power-binding.ejs +1 -1
- package/server/views/power-rule.ejs +1 -1
- package/server/views/power.ejs +7 -7
- package/server/views/proxy.ejs +55 -24
- package/server/views/scripts.ejs +4 -43
- package/server/views/service-list.ejs +1 -1
- package/server/views/settings.ejs +5 -1
- package/server/views/switch-details.ejs +1 -1
- package/server/views/switch-list.ejs +4 -4
- package/server/views/switch-script.ejs +1 -1
- package/server/views/units.ejs +3 -4
- package/server/webapp.js +98 -7
- package/server/views/style.ejs +0 -65
package/accessories/accessory.js
CHANGED
|
@@ -49,7 +49,7 @@ class Accessory {
|
|
|
49
49
|
getState(next) {
|
|
50
50
|
if (this.unit) {
|
|
51
51
|
(0, logger_1.log)("accessory", "getState was called for " + this.unit.node.getName() + " - " + this.unit.getName() + " -> " + this.unit.status);
|
|
52
|
-
next(null, this.unit.status);
|
|
52
|
+
next(null, !!this.unit.status);
|
|
53
53
|
}
|
|
54
54
|
else {
|
|
55
55
|
next(new Error("accessory - getState needs to be overridden if no unit is provided."));
|
|
@@ -81,7 +81,7 @@ class Accessory {
|
|
|
81
81
|
next(new Error("accessory - setState needs to be overridden if no unit is provided."));
|
|
82
82
|
}
|
|
83
83
|
updateState() {
|
|
84
|
-
this.me.getCharacteristic(this.homebridge.Characteristic.On).updateValue(this.unit.status);
|
|
84
|
+
this.me.getCharacteristic(this.homebridge.Characteristic.On).updateValue(!!this.unit.status);
|
|
85
85
|
(0, logger_1.log)("accessory", "Received status change -> update accessory -> " + this.unit.getName() + " -> on = " + this.unit.status);
|
|
86
86
|
}
|
|
87
87
|
}
|
package/accessories/dimmer.js
CHANGED
|
@@ -28,9 +28,10 @@ class Dimmer extends accessory_1.Accessory {
|
|
|
28
28
|
return [lightbulbService];
|
|
29
29
|
}
|
|
30
30
|
getBrightness(next) {
|
|
31
|
+
var _a;
|
|
31
32
|
if (this.unit) {
|
|
32
33
|
(0, logger_1.log)("accessory", "getBrightness was called for " + this.unit.node.getName() + " - " + this.unit.getName() + " -> " + this.unit.value);
|
|
33
|
-
next(null, this.unit.value);
|
|
34
|
+
next(null, (_a = this.unit.value) !== null && _a !== void 0 ? _a : 0);
|
|
34
35
|
}
|
|
35
36
|
else {
|
|
36
37
|
next(new Error("accessory - getBrightness needs to be overridden if no unit is provided."));
|
|
@@ -21,9 +21,10 @@ class Temperature extends accessory_1.Accessory {
|
|
|
21
21
|
return [temperatureService];
|
|
22
22
|
}
|
|
23
23
|
getTemperature(next) {
|
|
24
|
+
var _a;
|
|
24
25
|
if (this.unit) {
|
|
25
26
|
(0, logger_1.log)("accessory", "getTemperature was called for " + this.unit.node.getName() + " - " + this.unit.getName() + " -> " + this.unit.value);
|
|
26
|
-
const limited = Math.max(-100, Math.min(100, this.unit.value / 10.0));
|
|
27
|
+
const limited = Math.max(-100, Math.min(100, ((_a = this.unit.value) !== null && _a !== void 0 ? _a : 0) / 10.0));
|
|
27
28
|
next(null, limited);
|
|
28
29
|
}
|
|
29
30
|
else {
|
package/duotecno/types.js
CHANGED
|
@@ -424,7 +424,11 @@ exports.Sanitizers = {
|
|
|
424
424
|
c.cloudPort = (config === null || config === void 0 ? void 0 : config.cloudPort) || proxy_1.kEmptyProxy.cloudPort;
|
|
425
425
|
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) || "";
|
|
426
426
|
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;
|
|
427
|
-
c.
|
|
427
|
+
c.masterConfigPort = (config === null || config === void 0 ? void 0 : config.masterConfigPort) || proxy_1.kEmptyProxy.masterConfigPort;
|
|
428
|
+
c.installationId = (config === null || config === void 0 ? void 0 : config.installationId) || proxy_1.kEmptyProxy.installationId;
|
|
429
|
+
c.nodeNumber = (typeof (config === null || config === void 0 ? void 0 : config.nodeNumber) === "number") ? config.nodeNumber : proxy_1.kEmptyProxy.nodeNumber;
|
|
430
|
+
c.httpEnabled = !!(config === null || config === void 0 ? void 0 : config.httpEnabled);
|
|
431
|
+
c.gatewayPhysicalId = (config === null || config === void 0 ? void 0 : config.gatewayPhysicalId) || proxy_1.kEmptyProxy.gatewayPhysicalId;
|
|
428
432
|
c.kind = "gw";
|
|
429
433
|
return c;
|
|
430
434
|
},
|
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.1.23",
|
|
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",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
],
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/Duotecno-developer/smartsocket.git"
|
|
39
|
+
"url": "git+https://github.com/Duotecno-developer/smartsocket.git"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"bonjour-service": "^1.3.0",
|
package/server/platform.js
CHANGED
|
@@ -125,7 +125,7 @@ class Platform extends base_1.Base {
|
|
|
125
125
|
}
|
|
126
126
|
// Always set proxy kind to "gw" when running under Platform/HomeBridge
|
|
127
127
|
// This prevents auto-restart on errors even if proxy is not configured
|
|
128
|
-
if (this.config.proxy && ((_a = this.config.proxy) === null || _a === void 0 ? void 0 : _a.
|
|
128
|
+
if (this.config.proxy && ((_a = this.config.proxy) === null || _a === void 0 ? void 0 : _a.installationId)) {
|
|
129
129
|
this.config.proxy.kind = "gw";
|
|
130
130
|
(0, proxy_1.setProxyConfig)(this.config.proxy);
|
|
131
131
|
(0, proxy_1.cleanStart)(true);
|
package/server/proxy.js
CHANGED
|
@@ -9,19 +9,81 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.Context = exports.cleanStart = exports.makeNewCloudConnection = exports.gCloudConnections = exports.setProxyConfig = exports.setWebApp = exports.kEmptyProxy = void 0;
|
|
12
|
+
exports.Context = exports.cleanStart = exports.makeNewCloudConnection = exports.gCloudConnections = exports.setProxyConfig = exports.setWebApp = exports.kEmptyProxy = exports.gatewayHttpPort = exports.makeDeviceId = exports.kMasterNodeNumber = void 0;
|
|
13
13
|
const net = require("net");
|
|
14
|
+
const os = require("os");
|
|
14
15
|
const child_process_1 = require("child_process");
|
|
15
16
|
const process_1 = require("process");
|
|
17
|
+
const config_1 = require("../duotecno/config");
|
|
18
|
+
// Fixed, non-configurable logical node number every Duotecno master identifies itself with
|
|
19
|
+
// on the bus -- used for both of the master's own registrations (raw protocol + Config API).
|
|
20
|
+
exports.kMasterNodeNumber = 0xfc;
|
|
21
|
+
// Registered names follow: [installationId].[node number as 2-digit hex].[protocol]:[port]
|
|
22
|
+
// e.g. "gm.coppieters.be.ff.http:80" (gateway web UI), "gm.coppieters.be.fc.tcp:5001" (master)
|
|
23
|
+
function makeDeviceId(config, nodeNumber, protocol, port) {
|
|
24
|
+
const nodeHex = (nodeNumber !== null && nodeNumber !== void 0 ? nodeNumber : 0xff).toString(16).padStart(2, '0');
|
|
25
|
+
return `${config.installationId}.${nodeHex}.${protocol}:${port}`;
|
|
26
|
+
}
|
|
27
|
+
exports.makeDeviceId = makeDeviceId;
|
|
28
|
+
// Best-effort read of the local HTTP port the gateway's own web UI is listening on.
|
|
29
|
+
function gatewayHttpPort() {
|
|
30
|
+
const port = gWebApp === null || gWebApp === void 0 ? void 0 : gWebApp.port;
|
|
31
|
+
if (Array.isArray(port))
|
|
32
|
+
return port[0] || 80;
|
|
33
|
+
return port || 80;
|
|
34
|
+
}
|
|
35
|
+
exports.gatewayHttpPort = gatewayHttpPort;
|
|
36
|
+
// The cloud server can tell apart two different physical devices that were accidentally
|
|
37
|
+
// registered under the same unique id (instead of assuming a reconnect and evicting the
|
|
38
|
+
// other one), given a "physical address" -- 1-8 hex digits, formatted "0x..." -- alongside
|
|
39
|
+
// the unique id at registration. Masters have a real one: the Duotecno bus physical address
|
|
40
|
+
// of their own logical node (kMasterNodeNumber). Returns '' if not known yet (e.g. the master
|
|
41
|
+
// hasn't finished its initial discovery), in which case no physical address is sent for this
|
|
42
|
+
// registration, same as old firmware.
|
|
43
|
+
function masterPhysicalAddress() {
|
|
44
|
+
const system = gWebApp === null || gWebApp === void 0 ? void 0 : gWebApp.system;
|
|
45
|
+
const master = (system === null || system === void 0 ? void 0 : system.findMaster) ? system.findMaster(config.masterAddress, config.masterPort) : null;
|
|
46
|
+
const node = (master === null || master === void 0 ? void 0 : master.findNode) ? master.findNode(exports.kMasterNodeNumber) : null;
|
|
47
|
+
const phys = node === null || node === void 0 ? void 0 : node.physicalAddress;
|
|
48
|
+
return (typeof phys === "number" && phys > 0) ? "0x" + phys.toString(16) : '';
|
|
49
|
+
}
|
|
50
|
+
// The gateway itself has no Duotecno bus address of its own. Prefer the last two groups of a
|
|
51
|
+
// non-internal IPv6 address (8 hex digits total, e.g. "2afa:23fa:b95:9ebf" -> last two groups
|
|
52
|
+
// "b95:9ebf" -> "0x0b959ebf") if one is available on this machine; otherwise fall back to a
|
|
53
|
+
// random id generated once and persisted in config.json, so it stays stable across restarts
|
|
54
|
+
// and lets the cloud tell two different physical gateways apart even if they were accidentally
|
|
55
|
+
// given the same installation id.
|
|
56
|
+
function gatewayPhysicalAddress() {
|
|
57
|
+
const interfaces = os.networkInterfaces();
|
|
58
|
+
for (const name in interfaces) {
|
|
59
|
+
for (const iface of interfaces[name] || []) {
|
|
60
|
+
if ((iface.family === "IPv6" || iface.family === 6) && !iface.internal) {
|
|
61
|
+
const groups = iface.address.split(":").filter(g => g.length > 0);
|
|
62
|
+
if (groups.length >= 2) {
|
|
63
|
+
const last2 = groups.slice(-2).map(g => g.padStart(4, '0')).join('');
|
|
64
|
+
return "0x" + last2;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!config.gatewayPhysicalId) {
|
|
70
|
+
config.gatewayPhysicalId = "0x" + Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, '0');
|
|
71
|
+
(0, config_1.setSubConfig)("proxy", config);
|
|
72
|
+
(0, config_1.setAPIConfig)();
|
|
73
|
+
log(`[PROXY] No IPv6 address available, generated and stored a random physical id for this gateway: ${config.gatewayPhysicalId}`);
|
|
74
|
+
}
|
|
75
|
+
return config.gatewayPhysicalId;
|
|
76
|
+
}
|
|
16
77
|
exports.kEmptyProxy = {
|
|
17
78
|
cloudServer: "masters.duotecno.eu",
|
|
18
79
|
cloudPort: 5097,
|
|
19
|
-
configPort: 5099,
|
|
20
80
|
masterAddress: "",
|
|
21
81
|
masterPort: 5001,
|
|
22
82
|
masterConfigPort: 8080,
|
|
23
|
-
|
|
24
|
-
|
|
83
|
+
installationId: "",
|
|
84
|
+
nodeNumber: 0xff,
|
|
85
|
+
httpEnabled: false,
|
|
86
|
+
gatewayPhysicalId: "",
|
|
25
87
|
debug: false,
|
|
26
88
|
kind: "gw" // default running under the gateway or other process manager -> don't restart yourself
|
|
27
89
|
};
|
|
@@ -37,12 +99,13 @@ function setProxyConfig(c) {
|
|
|
37
99
|
if (c) {
|
|
38
100
|
config.cloudServer = c.cloudServer || exports.kEmptyProxy.cloudServer;
|
|
39
101
|
config.cloudPort = c.cloudPort || exports.kEmptyProxy.cloudPort;
|
|
40
|
-
config.configPort = c.configPort || exports.kEmptyProxy.configPort;
|
|
41
102
|
config.masterAddress = c.masterAddress || exports.kEmptyProxy.masterAddress;
|
|
42
103
|
config.masterPort = c.masterPort || exports.kEmptyProxy.masterPort;
|
|
43
104
|
config.masterConfigPort = c.masterConfigPort || exports.kEmptyProxy.masterConfigPort;
|
|
44
|
-
config.
|
|
45
|
-
config.
|
|
105
|
+
config.installationId = c.installationId || exports.kEmptyProxy.installationId;
|
|
106
|
+
config.nodeNumber = (typeof c.nodeNumber === "number") ? c.nodeNumber : exports.kEmptyProxy.nodeNumber;
|
|
107
|
+
config.httpEnabled = !!c.httpEnabled;
|
|
108
|
+
config.gatewayPhysicalId = c.gatewayPhysicalId || exports.kEmptyProxy.gatewayPhysicalId;
|
|
46
109
|
config.debug = !!c.debug;
|
|
47
110
|
config.kind = c.kind || exports.kEmptyProxy.kind;
|
|
48
111
|
}
|
|
@@ -85,27 +148,40 @@ catch (e) {
|
|
|
85
148
|
setProxyConfig();
|
|
86
149
|
log("[PROXY] No config-proxy.json found, waiting for Platform to start proxy...");
|
|
87
150
|
}
|
|
88
|
-
function makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId, count = 1) {
|
|
151
|
+
function makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId, count = 1, isGateway = false) {
|
|
89
152
|
// retry making a cloud connection
|
|
90
153
|
function reconnect(err) {
|
|
91
154
|
error(`[PROXY] → [CLOUD] Failed to connect ${count} times to the Cloud server: ${err.code || err.message}`);
|
|
92
155
|
if (count < 3) {
|
|
93
156
|
setTimeout(() => {
|
|
94
|
-
makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId, count + 1);
|
|
157
|
+
makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId, count + 1, isGateway);
|
|
95
158
|
}, 1000 * count * count); // exponential backoff: 1 second, 4 seconds, 9 seconds -> give up
|
|
96
159
|
// we check every 16 seconds for a free connection, if none found -> restart
|
|
97
160
|
}
|
|
98
161
|
}
|
|
162
|
+
// lets the cloud tell two genuinely different physical devices apart if they were ever
|
|
163
|
+
// accidentally configured with the same unique id, instead of assuming a reconnect
|
|
164
|
+
const physicalAddress = isGateway ? gatewayPhysicalAddress() : masterPhysicalAddress();
|
|
99
165
|
const cloudSocket = new net.Socket();
|
|
100
166
|
cloudSocket.once('error', reconnect);
|
|
101
|
-
|
|
167
|
+
// net.Socket.connect() has no default timeout: if the TCP handshake to the cloud gets
|
|
168
|
+
// silently dropped (packet loss, a flaky hop, no RST/FIN ever coming back), the socket
|
|
169
|
+
// just hangs forever with none of 'connect'/'error'/'close' ever firing, so this attempt
|
|
170
|
+
// would otherwise never retry and permanently lose this uniqueId's slot. Force it through
|
|
171
|
+
// the existing reconnect/backoff path instead.
|
|
172
|
+
cloudSocket.setTimeout(10000);
|
|
173
|
+
cloudSocket.once('timeout', () => {
|
|
174
|
+
cloudSocket.destroy(new Error('connection attempt timed out'));
|
|
175
|
+
});
|
|
176
|
+
log(`[PROXY] → [CLOUD] Attempt ${count} to start a new free connection for ${uniqueId} to the Cloud at ${server}:${serverPort}`);
|
|
102
177
|
cloudSocket.connect(serverPort, server, () => {
|
|
103
178
|
log(`[PROXY] → [CLOUD] Connected to Cloud at ${server}:${serverPort}`);
|
|
179
|
+
cloudSocket.setTimeout(0); // connected -- no longer subject to the connect-attempt timeout
|
|
104
180
|
cloudSocket.removeListener('error', reconnect);
|
|
105
|
-
// Send unique ID as the first message to identify this proxy/device
|
|
106
|
-
cloudSocket.write(`[${uniqueId}]`);
|
|
107
|
-
debug(`[PROXY] → [CLOUD] Sent unique ID: ${uniqueId}`);
|
|
108
|
-
const context = new Context(cloudSocket, master, masterPort, server, serverPort, uniqueId);
|
|
181
|
+
// Send unique ID (and physical address, if known) as the first message to identify this proxy/device
|
|
182
|
+
cloudSocket.write(physicalAddress ? `[${uniqueId}][${physicalAddress}]` : `[${uniqueId}]`);
|
|
183
|
+
debug(`[PROXY] → [CLOUD] Sent unique ID: ${uniqueId}` + (physicalAddress ? ` physical address: ${physicalAddress}` : ''));
|
|
184
|
+
const context = new Context(cloudSocket, master, masterPort, server, serverPort, uniqueId, isGateway);
|
|
109
185
|
exports.gCloudConnections.push(context);
|
|
110
186
|
log(`[PROXY] → [CLOUD] New free connection #${exports.gCloudConnections.length} to the Cloud at ${server}:${serverPort}`);
|
|
111
187
|
});
|
|
@@ -127,25 +203,35 @@ function cleanStart(restart = false) {
|
|
|
127
203
|
if (restart) {
|
|
128
204
|
log(`[PROXY] → [CLOUD] Restarting proxy...`);
|
|
129
205
|
let connectionsStarted = 0;
|
|
130
|
-
// Start connection for the master device if
|
|
131
|
-
|
|
132
|
-
|
|
206
|
+
// Start connection for the master device's raw protocol port if an installation ID is configured.
|
|
207
|
+
// Masters always register under the fixed master node number (kMasterNodeNumber), not the
|
|
208
|
+
// gateway's own (configurable) nodeNumber.
|
|
209
|
+
if (config.installationId && config.masterAddress) {
|
|
210
|
+
const masterId = makeDeviceId(config, exports.kMasterNodeNumber, "tcp", config.masterPort);
|
|
133
211
|
log(`[PROXY] Starting proxy for master device: ${masterId}`);
|
|
134
212
|
makeNewCloudConnection(config.masterAddress, config.masterPort, config.cloudServer, config.cloudPort, masterId);
|
|
135
213
|
connectionsStarted++;
|
|
136
214
|
}
|
|
137
|
-
else if (config.
|
|
138
|
-
log(`[PROXY] → [CLOUD]
|
|
215
|
+
else if (config.installationId) {
|
|
216
|
+
log(`[PROXY] → [CLOUD] Installation ID configured but no masterAddress, skipping master connection.`);
|
|
139
217
|
}
|
|
140
|
-
// Start
|
|
141
|
-
//
|
|
142
|
-
if (config.
|
|
143
|
-
const configId = config.
|
|
144
|
-
log(`[PROXY] Starting
|
|
218
|
+
// Start a proxy for the master's Config API port -- this is an HTTP API, so it's tagged
|
|
219
|
+
// "http" rather than "tcp" even though the tunnel itself just relays raw bytes either way.
|
|
220
|
+
if (config.installationId && config.masterAddress && config.masterConfigPort) {
|
|
221
|
+
const configId = makeDeviceId(config, exports.kMasterNodeNumber, "http", config.masterConfigPort);
|
|
222
|
+
log(`[PROXY] Starting proxy for Config API: ${configId} -> ${config.masterAddress}:${config.masterConfigPort}`);
|
|
145
223
|
// All connections use the same cloud port for WebSocket multiplexing
|
|
146
224
|
makeNewCloudConnection(config.masterAddress, config.masterConfigPort, config.cloudServer, config.cloudPort, configId);
|
|
147
225
|
connectionsStarted++;
|
|
148
226
|
}
|
|
227
|
+
// Register the gateway's own web UI (EJS) through the cloud, if enabled
|
|
228
|
+
if (config.installationId && config.httpEnabled) {
|
|
229
|
+
const httpPort = gatewayHttpPort();
|
|
230
|
+
const httpId = makeDeviceId(config, config.nodeNumber, "http", httpPort);
|
|
231
|
+
log(`[PROXY] Starting HTTP proxy for gateway web UI: ${httpId} -> localhost:${httpPort}`);
|
|
232
|
+
makeNewCloudConnection("localhost", httpPort, config.cloudServer, config.cloudPort, httpId, 1, true);
|
|
233
|
+
connectionsStarted++;
|
|
234
|
+
}
|
|
149
235
|
if (connectionsStarted > 0) {
|
|
150
236
|
log(`[PROXY] Started ${connectionsStarted} proxy connection(s) with config: ${JSON.stringify(config, null, 2)}`);
|
|
151
237
|
// check every 16 second for a free connection
|
|
@@ -170,7 +256,15 @@ function cleanStart(restart = false) {
|
|
|
170
256
|
}
|
|
171
257
|
exports.cleanStart = cleanStart;
|
|
172
258
|
class Context {
|
|
173
|
-
constructor(cloudSocket, master, masterPort, server, serverPort, uniqueId) {
|
|
259
|
+
constructor(cloudSocket, master, masterPort, server, serverPort, uniqueId, isGateway = false) {
|
|
260
|
+
// guards against handling the same unexpected drop twice (a socket error fires both
|
|
261
|
+
// 'error' and 'close') -- see handleUnexpectedClose()
|
|
262
|
+
this.closeHandled = false;
|
|
263
|
+
// deviceSocket is created synchronously but connect()s asynchronously; any further bytes of
|
|
264
|
+
// the SAME client request that arrive from the cloud before it's 'open' (a request split
|
|
265
|
+
// across TCP segments, or just event-loop timing under load) get buffered here and flushed
|
|
266
|
+
// once connected, instead of being silently dropped -- see handleDataFromCloud().
|
|
267
|
+
this.pendingDeviceData = [];
|
|
174
268
|
this.deviceSocket = null;
|
|
175
269
|
this.master = master;
|
|
176
270
|
this.masterPort = masterPort;
|
|
@@ -178,8 +272,8 @@ class Context {
|
|
|
178
272
|
this.server = server;
|
|
179
273
|
this.serverPort = serverPort;
|
|
180
274
|
this.uniqueId = uniqueId;
|
|
181
|
-
// Gateway connections
|
|
182
|
-
this.isGateway =
|
|
275
|
+
// Gateway connections forward to the gateway's own local web UI, not a Duotecno master
|
|
276
|
+
this.isGateway = isGateway;
|
|
183
277
|
this.deviceStatus = {
|
|
184
278
|
connected: false,
|
|
185
279
|
lastAttempt: null,
|
|
@@ -196,19 +290,39 @@ class Context {
|
|
|
196
290
|
});
|
|
197
291
|
this.cloudSocket.on('close', () => {
|
|
198
292
|
warning('[CLOUD] Connection closed');
|
|
199
|
-
|
|
200
|
-
this.deviceSocket.end();
|
|
201
|
-
this.deviceSocket = null;
|
|
202
|
-
}
|
|
293
|
+
this.handleUnexpectedClose();
|
|
203
294
|
});
|
|
204
295
|
this.cloudSocket.on('error', err => {
|
|
205
296
|
error(`[CLOUD] Error: ${err.message}`);
|
|
206
|
-
|
|
207
|
-
this.deviceSocket.end();
|
|
208
|
-
this.deviceSocket = null;
|
|
209
|
-
}
|
|
297
|
+
this.handleUnexpectedClose();
|
|
210
298
|
});
|
|
211
299
|
}
|
|
300
|
+
// Fires only for a drop we did NOT initiate ourselves: cleanupSockets() (the normal end-of-life
|
|
301
|
+
// path, used both after a consumed connection finishes and during a full cleanStart()) always
|
|
302
|
+
// calls cloudSocket.removeAllListeners() before closing it, so these 'close'/'error' handlers
|
|
303
|
+
// never see that. What they DO see is the cloud server closing/erroring a still-FREE (not yet
|
|
304
|
+
// consumed) connection out from under us -- e.g. a missed heartbeat while we're busy serving a
|
|
305
|
+
// burst of requests. Previously that silently dropped the registration for its uniqueId with
|
|
306
|
+
// nothing replacing it, until either an unrelated connection happened to replenish it or the
|
|
307
|
+
// 16s "no free connection anywhere" safety net forced a full, disruptive restart of every
|
|
308
|
+
// registration. Replenish immediately instead.
|
|
309
|
+
handleUnexpectedClose() {
|
|
310
|
+
if (this.closeHandled)
|
|
311
|
+
return; // 'error' is always followed by 'close' -- only act once
|
|
312
|
+
this.closeHandled = true;
|
|
313
|
+
const wasFree = !this.deviceSocket;
|
|
314
|
+
if (this.deviceSocket) {
|
|
315
|
+
this.deviceSocket.end();
|
|
316
|
+
this.deviceSocket = null;
|
|
317
|
+
}
|
|
318
|
+
const index = exports.gCloudConnections.indexOf(this);
|
|
319
|
+
if (index !== -1)
|
|
320
|
+
exports.gCloudConnections.splice(index, 1);
|
|
321
|
+
if (wasFree) {
|
|
322
|
+
warning(`[PROXY] → [CLOUD] Free connection for ${this.uniqueId} dropped unexpectedly, registering a replacement.`);
|
|
323
|
+
makeNewCloudConnection(this.master, this.masterPort, this.server, this.serverPort, this.uniqueId, 1, this.isGateway);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
212
326
|
handleDataFromCloud(data) {
|
|
213
327
|
debug(`[CLOUD] → [PROXY]: Data from Cloud: ${data.toString()}`);
|
|
214
328
|
if (!this.deviceSocket) {
|
|
@@ -226,20 +340,11 @@ class Context {
|
|
|
226
340
|
}
|
|
227
341
|
else {
|
|
228
342
|
// There is real incoming data, it's a fresh connection, so: a new client wants to connect
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
else {
|
|
236
|
-
// Device connection - create TCP proxy to master device
|
|
237
|
-
// 1) create a new (free) connection to the cloud server for the next client
|
|
238
|
-
// 2) connect to the device and send the initial data
|
|
239
|
-
log(`[PROXY] → [CLOUD] New client connection, creating new free connection for this proxy/device: ${this.uniqueId}.`);
|
|
240
|
-
makeNewCloudConnection(this.master, this.masterPort, this.server, this.serverPort, this.uniqueId);
|
|
241
|
-
this.makeDeviceConnection(this.master, this.masterPort, data);
|
|
242
|
-
}
|
|
343
|
+
// 1) create a new (free) connection to the cloud server for the next client
|
|
344
|
+
// 2) connect to the local target (gateway web UI, or the master device) and send the initial data
|
|
345
|
+
log(`[PROXY] → [CLOUD] New client connection, creating new free connection for this proxy/${this.isGateway ? "gateway" : "device"}: ${this.uniqueId}.`);
|
|
346
|
+
makeNewCloudConnection(this.master, this.masterPort, this.server, this.serverPort, this.uniqueId, 1, this.isGateway);
|
|
347
|
+
this.makeDeviceConnection(this.master, this.masterPort, data);
|
|
243
348
|
}
|
|
244
349
|
}
|
|
245
350
|
else if (this.deviceSocket.readyState === 'open') {
|
|
@@ -247,7 +352,9 @@ class Context {
|
|
|
247
352
|
debug(`[PROXY] → [DEVICE] forwarding data to device: ${data.toString().trim()}`);
|
|
248
353
|
}
|
|
249
354
|
else {
|
|
250
|
-
|
|
355
|
+
// still connecting to the local device -- buffer, don't drop; flushed in makeDeviceConnection()
|
|
356
|
+
debug(`[PROXY] → [DEVICE] Device socket not open yet, buffering ${data.length} bytes.`);
|
|
357
|
+
this.pendingDeviceData.push(data);
|
|
251
358
|
}
|
|
252
359
|
}
|
|
253
360
|
makeDeviceConnection(address, port, data) {
|
|
@@ -262,17 +369,26 @@ class Context {
|
|
|
262
369
|
this.deviceStatus.connected = false;
|
|
263
370
|
this.deviceStatus.lastError = err.message;
|
|
264
371
|
this.deviceStatus.retryCount++;
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
this
|
|
271
|
-
//
|
|
272
|
-
|
|
372
|
+
// The cloud already removed this tunnel from its free list the moment the client
|
|
373
|
+
// connected through it, so it's gone from its side regardless of what we do here --
|
|
374
|
+
// a fresh registration for this uniqueId was already kicked off back when this
|
|
375
|
+
// connection was first consumed. What we must NOT do is leave this Context sitting in
|
|
376
|
+
// gCloudConnections with deviceSocket === null: connectionChecker's "is there a free
|
|
377
|
+
// connection?" check would then mistake this dead entry for a healthy one (its
|
|
378
|
+
// cloudSocket may still be open) and never notice the tunnel is actually gone.
|
|
379
|
+
log(`[DEVICE] → [PROXY] Connection failed, cleaning up this tunnel.`);
|
|
380
|
+
this.removeConnection();
|
|
381
|
+
});
|
|
382
|
+
// give up (and clean up, same as an error) if connecting to our own local target hangs
|
|
383
|
+
this.deviceSocket.setTimeout(15000);
|
|
384
|
+
this.deviceSocket.once('timeout', () => {
|
|
385
|
+
var _a;
|
|
386
|
+
(_a = this.deviceSocket) === null || _a === void 0 ? void 0 : _a.destroy(new Error('connection attempt timed out'));
|
|
273
387
|
});
|
|
274
388
|
// Connect to local device and send the data that came in from the cloud
|
|
275
389
|
this.deviceSocket.connect(port, address, () => {
|
|
390
|
+
var _a, _b;
|
|
391
|
+
(_a = this.deviceSocket) === null || _a === void 0 ? void 0 : _a.setTimeout(0); // connected -- no longer subject to the connect timeout
|
|
276
392
|
// Check if socket is still valid (not cleaned up by removeConnection)
|
|
277
393
|
if (!this.deviceSocket) {
|
|
278
394
|
warning(`[PROXY] → [DEVICE] Device socket was cleaned up before connection completed`);
|
|
@@ -287,6 +403,13 @@ class Context {
|
|
|
287
403
|
this.setUpDeviceSocket();
|
|
288
404
|
log(`[PROXY] → [DEVICE] Sending initial message: ${data.toString().trim()}`);
|
|
289
405
|
this.deviceSocket.write(data);
|
|
406
|
+
// flush anything that arrived from the cloud while we were still connecting, in order
|
|
407
|
+
if (this.pendingDeviceData.length) {
|
|
408
|
+
debug(`[PROXY] → [DEVICE] Flushing ${this.pendingDeviceData.length} buffered chunk(s).`);
|
|
409
|
+
for (const chunk of this.pendingDeviceData)
|
|
410
|
+
(_b = this.deviceSocket) === null || _b === void 0 ? void 0 : _b.write(chunk);
|
|
411
|
+
this.pendingDeviceData = [];
|
|
412
|
+
}
|
|
290
413
|
});
|
|
291
414
|
}
|
|
292
415
|
else if (this.deviceSocket.readyState === 'open') {
|
|
@@ -314,23 +437,31 @@ class Context {
|
|
|
314
437
|
// Note: error handler is already added in makeDeviceConnection()
|
|
315
438
|
// Don't add duplicate error handler here
|
|
316
439
|
}
|
|
440
|
+
// socket.write() only queues bytes -- it does not guarantee they've reached the network yet.
|
|
441
|
+
// end() politely flushes-then-closes, but destroy() called right after it does a hard abort
|
|
442
|
+
// that discards anything still sitting in the send buffer. The local (loopback) leg of a
|
|
443
|
+
// request finishes near-instantly, well before the remote (real internet) leg has actually
|
|
444
|
+
// finished transmitting a large response, so destroying immediately after end() was silently
|
|
445
|
+
// truncating every larger response (e.g. min.js/min.css) at whatever had made it out before
|
|
446
|
+
// cleanup ran. Give end() a real chance to drain; only force-destroy as a fallback in case a
|
|
447
|
+
// peer never acknowledges, so a socket can't linger forever either.
|
|
448
|
+
gracefulEnd(socket) {
|
|
449
|
+
if (socket.destroyed)
|
|
450
|
+
return;
|
|
451
|
+
socket.removeAllListeners();
|
|
452
|
+
socket.end();
|
|
453
|
+
setTimeout(() => { if (!socket.destroyed)
|
|
454
|
+
socket.destroy(); }, 5000);
|
|
455
|
+
}
|
|
317
456
|
cleanupSockets() {
|
|
318
457
|
// Clean up the device socket
|
|
319
458
|
if (this.deviceSocket) {
|
|
320
|
-
|
|
321
|
-
this.deviceSocket.removeAllListeners();
|
|
322
|
-
this.deviceSocket.end();
|
|
323
|
-
this.deviceSocket.destroy();
|
|
324
|
-
}
|
|
459
|
+
this.gracefulEnd(this.deviceSocket);
|
|
325
460
|
this.deviceSocket = null;
|
|
326
461
|
}
|
|
327
462
|
// Clean up the cloud socket
|
|
328
463
|
if (this.cloudSocket) {
|
|
329
|
-
|
|
330
|
-
this.cloudSocket.removeAllListeners();
|
|
331
|
-
this.cloudSocket.end();
|
|
332
|
-
this.cloudSocket.destroy();
|
|
333
|
-
}
|
|
464
|
+
this.gracefulEnd(this.cloudSocket);
|
|
334
465
|
this.cloudSocket = null;
|
|
335
466
|
}
|
|
336
467
|
}
|
package/server/smartapp.js
CHANGED
|
@@ -48,6 +48,18 @@ const kPin = { name: "pin", type: "string", default: "577-03-001" };
|
|
|
48
48
|
// Mac file system -> write locally
|
|
49
49
|
const isMac = (0, fs_1.existsSync)("/Volumes");
|
|
50
50
|
const kDHCPConfigFile = isMac ? "./config.dhcpcd" : "/etc/dhcpcd.conf";
|
|
51
|
+
// Simple minifier for our own hand-authored CSS (never run over vendor files, which ship
|
|
52
|
+
// pre-minified): strips comments and collapses whitespace around punctuation. Not safe for
|
|
53
|
+
// arbitrary CSS (e.g. strings containing "/*", or spacing-sensitive calc()) but fine for the
|
|
54
|
+
// plain rules in custom.css.
|
|
55
|
+
function minifyCSS(css) {
|
|
56
|
+
return css
|
|
57
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
58
|
+
.replace(/\s+/g, " ")
|
|
59
|
+
.replace(/\s*([{}:;,])\s*/g, "$1")
|
|
60
|
+
.replace(/;}/g, "}")
|
|
61
|
+
.trim();
|
|
62
|
+
}
|
|
51
63
|
class SmartApp extends webapp_1.WebApp {
|
|
52
64
|
constructor(system, power, platform) {
|
|
53
65
|
super("smartapp");
|
|
@@ -108,7 +120,46 @@ class SmartApp extends webapp_1.WebApp {
|
|
|
108
120
|
this.addFile("materializeJS", __dirname + "/views/assets/materialize.min.js", "text/javascript");
|
|
109
121
|
this.addFile("favicon", __dirname + "/views/assets/favicon.ico", "image/x-icon");
|
|
110
122
|
this.addFile("logoWhite", __dirname + "/views/assets/Duotecno_logo_white.svg", "image/svg+xml");
|
|
123
|
+
this.addFile("customCSS", __dirname + "/views/assets/custom.css", "text/css");
|
|
111
124
|
this.addFile("proxy", __dirname + "/views/proxy.ejs", "text/html");
|
|
125
|
+
this.buildBundle();
|
|
126
|
+
}
|
|
127
|
+
// Merge everything a page needs to look/behave right (Materialize CSS+JS, our own layout
|
|
128
|
+
// CSS, the Material Icons webfont) into a single cacheable /files/bundle.<version>.js so a
|
|
129
|
+
// page load needs at most 2 HTTP requests (the HTML itself, plus this bundle once per
|
|
130
|
+
// browser cache lifetime) instead of one per asset -- requests that get dropped when served
|
|
131
|
+
// through a flaky/limited proxy. It's loaded as a blocking <script> in <head> so it can
|
|
132
|
+
// inject the CSS (as a <style> element) before the body is parsed, avoiding a flash of
|
|
133
|
+
// unstyled content.
|
|
134
|
+
buildBundle() {
|
|
135
|
+
const fontBase64 = (0, fs_1.readFileSync)(__dirname + "/views/assets/material-icons.woff2").toString("base64");
|
|
136
|
+
const fontFaceCSS = `@font-face{font-family:'Material Icons';font-style:normal;font-weight:400;` +
|
|
137
|
+
`src:url(data:font/woff2;base64,${fontBase64}) format('woff2')}` +
|
|
138
|
+
`.material-icons{font-family:'Material Icons';font-weight:normal;font-style:normal;font-size:24px;` +
|
|
139
|
+
`line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;white-space:nowrap;` +
|
|
140
|
+
`word-wrap:normal;direction:ltr;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased}`;
|
|
141
|
+
// materializeCSS/JS are already minified vendor files -- left untouched. Only our own
|
|
142
|
+
// hand-authored custom.css and the snippet below get compacted, since we know exactly
|
|
143
|
+
// what's in them (no url()/content: strings, every JS statement already ; or } terminated).
|
|
144
|
+
const bundleCSS = this.getFile("materializeCSS").content + "\n" + minifyCSS(this.getFile("customCSS").content) + "\n" + fontFaceCSS;
|
|
145
|
+
const customJS = `function hex(val){var num=parseInt(val,10);if(isNaN(num))return "0x0";return "0x"+num.toString(16);}
|
|
146
|
+
function ajax(url,success){var xhr=window.XMLHttpRequest?new XMLHttpRequest():new ActiveXObject('Microsoft.XMLHTTP');xhr.open('GET',url);xhr.onreadystatechange=function(){if(xhr.readyState>3&&xhr.status==200)success(xhr.responseText);};xhr.setRequestHeader('X-Requested-With','XMLHttpRequest');xhr.send();return xhr;}
|
|
147
|
+
document.addEventListener("keyup",function(e){if(e.key=="Enter")e.preventDefault();});
|
|
148
|
+
function SmartSocketInit(message){
|
|
149
|
+
Array.prototype.forEach.call(document.getElementsByClassName("indeterminate"),function(element){element.indeterminate=true;});
|
|
150
|
+
M.AutoInit();
|
|
151
|
+
var sidenavElems=document.querySelectorAll('.sidenav');
|
|
152
|
+
M.Sidenav.init(sidenavElems,{edge:'right',draggable:true});
|
|
153
|
+
if(message)M.toast({html:message});
|
|
154
|
+
console.log("Ready to rock & roll");
|
|
155
|
+
}`.replace(/\n\s*/g, "");
|
|
156
|
+
const bundleJS = `(function(){var s=document.createElement('style');s.textContent=${JSON.stringify(bundleCSS)};document.head.appendChild(s);})();\n` +
|
|
157
|
+
this.getFile("materializeJS").content + "\n" + customJS;
|
|
158
|
+
this.addContent("bundle", bundleJS, "application/javascript", "bundle.js");
|
|
159
|
+
const logoSvg = this.getFile("logoWhite").content.replace("<svg ", '<svg class="brand-logo-svg" ');
|
|
160
|
+
this.addContent("logoWhiteInline", logoSvg, "image/svg+xml", "logo.svg");
|
|
161
|
+
const faviconBase64 = (0, fs_1.readFileSync)(__dirname + "/views/assets/favicon.ico").toString("base64");
|
|
162
|
+
this.addContent("faviconDataUri", `data:image/x-icon;base64,${faviconBase64}`, "text/plain", "favicon.ico");
|
|
112
163
|
}
|
|
113
164
|
// Override to use config.system.debug for template caching control
|
|
114
165
|
isDebugMode() {
|
|
@@ -330,12 +381,13 @@ class SmartApp extends webapp_1.WebApp {
|
|
|
330
381
|
// save the proxy config
|
|
331
382
|
config.cloudServer = context.getParam({ name: "cloudServer", type: "string" });
|
|
332
383
|
config.cloudPort = context.getParam({ name: "cloudPort", type: "integer" });
|
|
333
|
-
config.configPort = context.getParam({ name: "configPort", type: "integer" });
|
|
334
384
|
config.masterAddress = context.getParam({ name: "masterAddress", type: "string" });
|
|
335
385
|
config.masterPort = context.getParam({ name: "masterPort", type: "integer" });
|
|
336
386
|
config.masterConfigPort = context.getParam({ name: "masterConfigPort", type: "integer" });
|
|
337
|
-
config.
|
|
338
|
-
config.
|
|
387
|
+
config.installationId = context.getParam({ name: "installationId", type: "string" });
|
|
388
|
+
config.nodeNumber = context.getParam({ name: "nodeNumber", type: "string" })
|
|
389
|
+
? parseInt(context.getParam({ name: "nodeNumber", type: "string" }), 16) : proxy_1.kEmptyProxy.nodeNumber;
|
|
390
|
+
config.httpEnabled = !!context.getParam({ name: "httpEnabled", type: "string" });
|
|
339
391
|
this.write("proxy", config);
|
|
340
392
|
(0, proxy_1.setProxyConfig)(config);
|
|
341
393
|
// Automatically restart proxy with new config
|
|
@@ -353,7 +405,14 @@ class SmartApp extends webapp_1.WebApp {
|
|
|
353
405
|
registrationError: this.mdnsService.registrationError,
|
|
354
406
|
registrationStatus: this.mdnsService.registrationStatus
|
|
355
407
|
} : null;
|
|
356
|
-
|
|
408
|
+
// Names this gateway will register on the cloud server with the current config.
|
|
409
|
+
// The master's two connections always use the fixed master node number, not config.nodeNumber.
|
|
410
|
+
const deviceIds = {
|
|
411
|
+
tcp: (0, proxy_1.makeDeviceId)(config, proxy_1.kMasterNodeNumber, "tcp", config.masterPort || proxy_1.kEmptyProxy.masterPort),
|
|
412
|
+
configHttp: (0, proxy_1.makeDeviceId)(config, proxy_1.kMasterNodeNumber, "http", config.masterConfigPort || proxy_1.kEmptyProxy.masterConfigPort || 8080),
|
|
413
|
+
http: (0, proxy_1.makeDeviceId)(config, config.nodeNumber, "http", (0, proxy_1.gatewayHttpPort)()),
|
|
414
|
+
};
|
|
415
|
+
return this.ejs("proxy", context, { config, connections: proxy_1.gCloudConnections, mdnsStatus, deviceIds });
|
|
357
416
|
});
|
|
358
417
|
}
|
|
359
418
|
//////////////
|
|
@@ -1563,7 +1622,15 @@ class SmartApp extends webapp_1.WebApp {
|
|
|
1563
1622
|
//////////////////////////////
|
|
1564
1623
|
renderAssets(context) {
|
|
1565
1624
|
const file = context.action;
|
|
1566
|
-
if ((
|
|
1625
|
+
if (file.startsWith("bundle.") && file.endsWith(".js")) {
|
|
1626
|
+
// filename carries the plugin version so a release automatically busts old caches
|
|
1627
|
+
const bundle = this.getFile("bundle");
|
|
1628
|
+
return {
|
|
1629
|
+
status: 200, type: bundle.type, data: bundle.content,
|
|
1630
|
+
header: { "Cache-Control": "public, max-age=31536000, immutable" }
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
else if ((file === "min.css") || (file === "materialize.min.css") || (file === "materialize.css")) {
|
|
1567
1634
|
return this.file("materializeCSS");
|
|
1568
1635
|
}
|
|
1569
1636
|
else if ((file === "min.js") || (file === "materialize.min.js") || (file === "materialize.js")) {
|