homebridge-smartsystem 7.1.21 → 7.1.22
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 +4 -1
- package/package.json +1 -1
- package/server/platform.js +1 -1
- package/server/proxy.js +148 -65
- 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/duotecno/types.js
CHANGED
|
@@ -424,7 +424,10 @@ 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);
|
|
428
431
|
c.kind = "gw";
|
|
429
432
|
return c;
|
|
430
433
|
},
|
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.22",
|
|
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/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,37 @@ 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
14
|
const child_process_1 = require("child_process");
|
|
15
15
|
const process_1 = require("process");
|
|
16
|
+
// Fixed, non-configurable logical node number every Duotecno master identifies itself with
|
|
17
|
+
// on the bus -- used for both of the master's own registrations (raw protocol + Config API).
|
|
18
|
+
exports.kMasterNodeNumber = 0xfc;
|
|
19
|
+
// Registered names follow: [installationId].[node number as 2-digit hex].[protocol]:[port]
|
|
20
|
+
// e.g. "gm.coppieters.be.ff.http:80" (gateway web UI), "gm.coppieters.be.fc.tcp:5001" (master)
|
|
21
|
+
function makeDeviceId(config, nodeNumber, protocol, port) {
|
|
22
|
+
const nodeHex = (nodeNumber !== null && nodeNumber !== void 0 ? nodeNumber : 0xff).toString(16).padStart(2, '0');
|
|
23
|
+
return `${config.installationId}.${nodeHex}.${protocol}:${port}`;
|
|
24
|
+
}
|
|
25
|
+
exports.makeDeviceId = makeDeviceId;
|
|
26
|
+
// Best-effort read of the local HTTP port the gateway's own web UI is listening on.
|
|
27
|
+
function gatewayHttpPort() {
|
|
28
|
+
const port = gWebApp === null || gWebApp === void 0 ? void 0 : gWebApp.port;
|
|
29
|
+
if (Array.isArray(port))
|
|
30
|
+
return port[0] || 80;
|
|
31
|
+
return port || 80;
|
|
32
|
+
}
|
|
33
|
+
exports.gatewayHttpPort = gatewayHttpPort;
|
|
16
34
|
exports.kEmptyProxy = {
|
|
17
35
|
cloudServer: "masters.duotecno.eu",
|
|
18
36
|
cloudPort: 5097,
|
|
19
|
-
configPort: 5099,
|
|
20
37
|
masterAddress: "",
|
|
21
38
|
masterPort: 5001,
|
|
22
39
|
masterConfigPort: 8080,
|
|
23
|
-
|
|
24
|
-
|
|
40
|
+
installationId: "",
|
|
41
|
+
nodeNumber: 0xff,
|
|
42
|
+
httpEnabled: false,
|
|
25
43
|
debug: false,
|
|
26
44
|
kind: "gw" // default running under the gateway or other process manager -> don't restart yourself
|
|
27
45
|
};
|
|
@@ -37,12 +55,12 @@ function setProxyConfig(c) {
|
|
|
37
55
|
if (c) {
|
|
38
56
|
config.cloudServer = c.cloudServer || exports.kEmptyProxy.cloudServer;
|
|
39
57
|
config.cloudPort = c.cloudPort || exports.kEmptyProxy.cloudPort;
|
|
40
|
-
config.configPort = c.configPort || exports.kEmptyProxy.configPort;
|
|
41
58
|
config.masterAddress = c.masterAddress || exports.kEmptyProxy.masterAddress;
|
|
42
59
|
config.masterPort = c.masterPort || exports.kEmptyProxy.masterPort;
|
|
43
60
|
config.masterConfigPort = c.masterConfigPort || exports.kEmptyProxy.masterConfigPort;
|
|
44
|
-
config.
|
|
45
|
-
config.
|
|
61
|
+
config.installationId = c.installationId || exports.kEmptyProxy.installationId;
|
|
62
|
+
config.nodeNumber = (typeof c.nodeNumber === "number") ? c.nodeNumber : exports.kEmptyProxy.nodeNumber;
|
|
63
|
+
config.httpEnabled = !!c.httpEnabled;
|
|
46
64
|
config.debug = !!c.debug;
|
|
47
65
|
config.kind = c.kind || exports.kEmptyProxy.kind;
|
|
48
66
|
}
|
|
@@ -85,27 +103,37 @@ catch (e) {
|
|
|
85
103
|
setProxyConfig();
|
|
86
104
|
log("[PROXY] No config-proxy.json found, waiting for Platform to start proxy...");
|
|
87
105
|
}
|
|
88
|
-
function makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId, count = 1) {
|
|
106
|
+
function makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId, count = 1, isGateway = false) {
|
|
89
107
|
// retry making a cloud connection
|
|
90
108
|
function reconnect(err) {
|
|
91
109
|
error(`[PROXY] → [CLOUD] Failed to connect ${count} times to the Cloud server: ${err.code || err.message}`);
|
|
92
110
|
if (count < 3) {
|
|
93
111
|
setTimeout(() => {
|
|
94
|
-
makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId, count + 1);
|
|
112
|
+
makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId, count + 1, isGateway);
|
|
95
113
|
}, 1000 * count * count); // exponential backoff: 1 second, 4 seconds, 9 seconds -> give up
|
|
96
114
|
// we check every 16 seconds for a free connection, if none found -> restart
|
|
97
115
|
}
|
|
98
116
|
}
|
|
99
117
|
const cloudSocket = new net.Socket();
|
|
100
118
|
cloudSocket.once('error', reconnect);
|
|
101
|
-
|
|
119
|
+
// net.Socket.connect() has no default timeout: if the TCP handshake to the cloud gets
|
|
120
|
+
// silently dropped (packet loss, a flaky hop, no RST/FIN ever coming back), the socket
|
|
121
|
+
// just hangs forever with none of 'connect'/'error'/'close' ever firing, so this attempt
|
|
122
|
+
// would otherwise never retry and permanently lose this uniqueId's slot. Force it through
|
|
123
|
+
// the existing reconnect/backoff path instead.
|
|
124
|
+
cloudSocket.setTimeout(10000);
|
|
125
|
+
cloudSocket.once('timeout', () => {
|
|
126
|
+
cloudSocket.destroy(new Error('connection attempt timed out'));
|
|
127
|
+
});
|
|
128
|
+
log(`[PROXY] → [CLOUD] Attempt ${count} to start a new free connection for ${uniqueId} to the Cloud at ${server}:${serverPort}`);
|
|
102
129
|
cloudSocket.connect(serverPort, server, () => {
|
|
103
130
|
log(`[PROXY] → [CLOUD] Connected to Cloud at ${server}:${serverPort}`);
|
|
131
|
+
cloudSocket.setTimeout(0); // connected -- no longer subject to the connect-attempt timeout
|
|
104
132
|
cloudSocket.removeListener('error', reconnect);
|
|
105
133
|
// Send unique ID as the first message to identify this proxy/device
|
|
106
134
|
cloudSocket.write(`[${uniqueId}]`);
|
|
107
135
|
debug(`[PROXY] → [CLOUD] Sent unique ID: ${uniqueId}`);
|
|
108
|
-
const context = new Context(cloudSocket, master, masterPort, server, serverPort, uniqueId);
|
|
136
|
+
const context = new Context(cloudSocket, master, masterPort, server, serverPort, uniqueId, isGateway);
|
|
109
137
|
exports.gCloudConnections.push(context);
|
|
110
138
|
log(`[PROXY] → [CLOUD] New free connection #${exports.gCloudConnections.length} to the Cloud at ${server}:${serverPort}`);
|
|
111
139
|
});
|
|
@@ -127,25 +155,35 @@ function cleanStart(restart = false) {
|
|
|
127
155
|
if (restart) {
|
|
128
156
|
log(`[PROXY] → [CLOUD] Restarting proxy...`);
|
|
129
157
|
let connectionsStarted = 0;
|
|
130
|
-
// Start connection for the master device if
|
|
131
|
-
|
|
132
|
-
|
|
158
|
+
// Start connection for the master device's raw protocol port if an installation ID is configured.
|
|
159
|
+
// Masters always register under the fixed master node number (kMasterNodeNumber), not the
|
|
160
|
+
// gateway's own (configurable) nodeNumber.
|
|
161
|
+
if (config.installationId && config.masterAddress) {
|
|
162
|
+
const masterId = makeDeviceId(config, exports.kMasterNodeNumber, "tcp", config.masterPort);
|
|
133
163
|
log(`[PROXY] Starting proxy for master device: ${masterId}`);
|
|
134
164
|
makeNewCloudConnection(config.masterAddress, config.masterPort, config.cloudServer, config.cloudPort, masterId);
|
|
135
165
|
connectionsStarted++;
|
|
136
166
|
}
|
|
137
|
-
else if (config.
|
|
138
|
-
log(`[PROXY] → [CLOUD]
|
|
167
|
+
else if (config.installationId) {
|
|
168
|
+
log(`[PROXY] → [CLOUD] Installation ID configured but no masterAddress, skipping master connection.`);
|
|
139
169
|
}
|
|
140
|
-
// Start
|
|
141
|
-
//
|
|
142
|
-
if (config.
|
|
143
|
-
const configId = config.
|
|
144
|
-
log(`[PROXY] Starting
|
|
170
|
+
// Start a proxy for the master's Config API port -- this is an HTTP API, so it's tagged
|
|
171
|
+
// "http" rather than "tcp" even though the tunnel itself just relays raw bytes either way.
|
|
172
|
+
if (config.installationId && config.masterAddress && config.masterConfigPort) {
|
|
173
|
+
const configId = makeDeviceId(config, exports.kMasterNodeNumber, "http", config.masterConfigPort);
|
|
174
|
+
log(`[PROXY] Starting proxy for Config API: ${configId} -> ${config.masterAddress}:${config.masterConfigPort}`);
|
|
145
175
|
// All connections use the same cloud port for WebSocket multiplexing
|
|
146
176
|
makeNewCloudConnection(config.masterAddress, config.masterConfigPort, config.cloudServer, config.cloudPort, configId);
|
|
147
177
|
connectionsStarted++;
|
|
148
178
|
}
|
|
179
|
+
// Register the gateway's own web UI (EJS) through the cloud, if enabled
|
|
180
|
+
if (config.installationId && config.httpEnabled) {
|
|
181
|
+
const httpPort = gatewayHttpPort();
|
|
182
|
+
const httpId = makeDeviceId(config, config.nodeNumber, "http", httpPort);
|
|
183
|
+
log(`[PROXY] Starting HTTP proxy for gateway web UI: ${httpId} -> localhost:${httpPort}`);
|
|
184
|
+
makeNewCloudConnection("localhost", httpPort, config.cloudServer, config.cloudPort, httpId, 1, true);
|
|
185
|
+
connectionsStarted++;
|
|
186
|
+
}
|
|
149
187
|
if (connectionsStarted > 0) {
|
|
150
188
|
log(`[PROXY] Started ${connectionsStarted} proxy connection(s) with config: ${JSON.stringify(config, null, 2)}`);
|
|
151
189
|
// check every 16 second for a free connection
|
|
@@ -170,7 +208,15 @@ function cleanStart(restart = false) {
|
|
|
170
208
|
}
|
|
171
209
|
exports.cleanStart = cleanStart;
|
|
172
210
|
class Context {
|
|
173
|
-
constructor(cloudSocket, master, masterPort, server, serverPort, uniqueId) {
|
|
211
|
+
constructor(cloudSocket, master, masterPort, server, serverPort, uniqueId, isGateway = false) {
|
|
212
|
+
// guards against handling the same unexpected drop twice (a socket error fires both
|
|
213
|
+
// 'error' and 'close') -- see handleUnexpectedClose()
|
|
214
|
+
this.closeHandled = false;
|
|
215
|
+
// deviceSocket is created synchronously but connect()s asynchronously; any further bytes of
|
|
216
|
+
// the SAME client request that arrive from the cloud before it's 'open' (a request split
|
|
217
|
+
// across TCP segments, or just event-loop timing under load) get buffered here and flushed
|
|
218
|
+
// once connected, instead of being silently dropped -- see handleDataFromCloud().
|
|
219
|
+
this.pendingDeviceData = [];
|
|
174
220
|
this.deviceSocket = null;
|
|
175
221
|
this.master = master;
|
|
176
222
|
this.masterPort = masterPort;
|
|
@@ -178,8 +224,8 @@ class Context {
|
|
|
178
224
|
this.server = server;
|
|
179
225
|
this.serverPort = serverPort;
|
|
180
226
|
this.uniqueId = uniqueId;
|
|
181
|
-
// Gateway connections
|
|
182
|
-
this.isGateway =
|
|
227
|
+
// Gateway connections forward to the gateway's own local web UI, not a Duotecno master
|
|
228
|
+
this.isGateway = isGateway;
|
|
183
229
|
this.deviceStatus = {
|
|
184
230
|
connected: false,
|
|
185
231
|
lastAttempt: null,
|
|
@@ -196,19 +242,39 @@ class Context {
|
|
|
196
242
|
});
|
|
197
243
|
this.cloudSocket.on('close', () => {
|
|
198
244
|
warning('[CLOUD] Connection closed');
|
|
199
|
-
|
|
200
|
-
this.deviceSocket.end();
|
|
201
|
-
this.deviceSocket = null;
|
|
202
|
-
}
|
|
245
|
+
this.handleUnexpectedClose();
|
|
203
246
|
});
|
|
204
247
|
this.cloudSocket.on('error', err => {
|
|
205
248
|
error(`[CLOUD] Error: ${err.message}`);
|
|
206
|
-
|
|
207
|
-
this.deviceSocket.end();
|
|
208
|
-
this.deviceSocket = null;
|
|
209
|
-
}
|
|
249
|
+
this.handleUnexpectedClose();
|
|
210
250
|
});
|
|
211
251
|
}
|
|
252
|
+
// Fires only for a drop we did NOT initiate ourselves: cleanupSockets() (the normal end-of-life
|
|
253
|
+
// path, used both after a consumed connection finishes and during a full cleanStart()) always
|
|
254
|
+
// calls cloudSocket.removeAllListeners() before closing it, so these 'close'/'error' handlers
|
|
255
|
+
// never see that. What they DO see is the cloud server closing/erroring a still-FREE (not yet
|
|
256
|
+
// consumed) connection out from under us -- e.g. a missed heartbeat while we're busy serving a
|
|
257
|
+
// burst of requests. Previously that silently dropped the registration for its uniqueId with
|
|
258
|
+
// nothing replacing it, until either an unrelated connection happened to replenish it or the
|
|
259
|
+
// 16s "no free connection anywhere" safety net forced a full, disruptive restart of every
|
|
260
|
+
// registration. Replenish immediately instead.
|
|
261
|
+
handleUnexpectedClose() {
|
|
262
|
+
if (this.closeHandled)
|
|
263
|
+
return; // 'error' is always followed by 'close' -- only act once
|
|
264
|
+
this.closeHandled = true;
|
|
265
|
+
const wasFree = !this.deviceSocket;
|
|
266
|
+
if (this.deviceSocket) {
|
|
267
|
+
this.deviceSocket.end();
|
|
268
|
+
this.deviceSocket = null;
|
|
269
|
+
}
|
|
270
|
+
const index = exports.gCloudConnections.indexOf(this);
|
|
271
|
+
if (index !== -1)
|
|
272
|
+
exports.gCloudConnections.splice(index, 1);
|
|
273
|
+
if (wasFree) {
|
|
274
|
+
warning(`[PROXY] → [CLOUD] Free connection for ${this.uniqueId} dropped unexpectedly, registering a replacement.`);
|
|
275
|
+
makeNewCloudConnection(this.master, this.masterPort, this.server, this.serverPort, this.uniqueId, 1, this.isGateway);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
212
278
|
handleDataFromCloud(data) {
|
|
213
279
|
debug(`[CLOUD] → [PROXY]: Data from Cloud: ${data.toString()}`);
|
|
214
280
|
if (!this.deviceSocket) {
|
|
@@ -226,20 +292,11 @@ class Context {
|
|
|
226
292
|
}
|
|
227
293
|
else {
|
|
228
294
|
// 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
|
-
}
|
|
295
|
+
// 1) create a new (free) connection to the cloud server for the next client
|
|
296
|
+
// 2) connect to the local target (gateway web UI, or the master device) and send the initial data
|
|
297
|
+
log(`[PROXY] → [CLOUD] New client connection, creating new free connection for this proxy/${this.isGateway ? "gateway" : "device"}: ${this.uniqueId}.`);
|
|
298
|
+
makeNewCloudConnection(this.master, this.masterPort, this.server, this.serverPort, this.uniqueId, 1, this.isGateway);
|
|
299
|
+
this.makeDeviceConnection(this.master, this.masterPort, data);
|
|
243
300
|
}
|
|
244
301
|
}
|
|
245
302
|
else if (this.deviceSocket.readyState === 'open') {
|
|
@@ -247,7 +304,9 @@ class Context {
|
|
|
247
304
|
debug(`[PROXY] → [DEVICE] forwarding data to device: ${data.toString().trim()}`);
|
|
248
305
|
}
|
|
249
306
|
else {
|
|
250
|
-
|
|
307
|
+
// still connecting to the local device -- buffer, don't drop; flushed in makeDeviceConnection()
|
|
308
|
+
debug(`[PROXY] → [DEVICE] Device socket not open yet, buffering ${data.length} bytes.`);
|
|
309
|
+
this.pendingDeviceData.push(data);
|
|
251
310
|
}
|
|
252
311
|
}
|
|
253
312
|
makeDeviceConnection(address, port, data) {
|
|
@@ -262,17 +321,26 @@ class Context {
|
|
|
262
321
|
this.deviceStatus.connected = false;
|
|
263
322
|
this.deviceStatus.lastError = err.message;
|
|
264
323
|
this.deviceStatus.retryCount++;
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
this
|
|
271
|
-
//
|
|
272
|
-
|
|
324
|
+
// The cloud already removed this tunnel from its free list the moment the client
|
|
325
|
+
// connected through it, so it's gone from its side regardless of what we do here --
|
|
326
|
+
// a fresh registration for this uniqueId was already kicked off back when this
|
|
327
|
+
// connection was first consumed. What we must NOT do is leave this Context sitting in
|
|
328
|
+
// gCloudConnections with deviceSocket === null: connectionChecker's "is there a free
|
|
329
|
+
// connection?" check would then mistake this dead entry for a healthy one (its
|
|
330
|
+
// cloudSocket may still be open) and never notice the tunnel is actually gone.
|
|
331
|
+
log(`[DEVICE] → [PROXY] Connection failed, cleaning up this tunnel.`);
|
|
332
|
+
this.removeConnection();
|
|
333
|
+
});
|
|
334
|
+
// give up (and clean up, same as an error) if connecting to our own local target hangs
|
|
335
|
+
this.deviceSocket.setTimeout(15000);
|
|
336
|
+
this.deviceSocket.once('timeout', () => {
|
|
337
|
+
var _a;
|
|
338
|
+
(_a = this.deviceSocket) === null || _a === void 0 ? void 0 : _a.destroy(new Error('connection attempt timed out'));
|
|
273
339
|
});
|
|
274
340
|
// Connect to local device and send the data that came in from the cloud
|
|
275
341
|
this.deviceSocket.connect(port, address, () => {
|
|
342
|
+
var _a, _b;
|
|
343
|
+
(_a = this.deviceSocket) === null || _a === void 0 ? void 0 : _a.setTimeout(0); // connected -- no longer subject to the connect timeout
|
|
276
344
|
// Check if socket is still valid (not cleaned up by removeConnection)
|
|
277
345
|
if (!this.deviceSocket) {
|
|
278
346
|
warning(`[PROXY] → [DEVICE] Device socket was cleaned up before connection completed`);
|
|
@@ -287,6 +355,13 @@ class Context {
|
|
|
287
355
|
this.setUpDeviceSocket();
|
|
288
356
|
log(`[PROXY] → [DEVICE] Sending initial message: ${data.toString().trim()}`);
|
|
289
357
|
this.deviceSocket.write(data);
|
|
358
|
+
// flush anything that arrived from the cloud while we were still connecting, in order
|
|
359
|
+
if (this.pendingDeviceData.length) {
|
|
360
|
+
debug(`[PROXY] → [DEVICE] Flushing ${this.pendingDeviceData.length} buffered chunk(s).`);
|
|
361
|
+
for (const chunk of this.pendingDeviceData)
|
|
362
|
+
(_b = this.deviceSocket) === null || _b === void 0 ? void 0 : _b.write(chunk);
|
|
363
|
+
this.pendingDeviceData = [];
|
|
364
|
+
}
|
|
290
365
|
});
|
|
291
366
|
}
|
|
292
367
|
else if (this.deviceSocket.readyState === 'open') {
|
|
@@ -314,23 +389,31 @@ class Context {
|
|
|
314
389
|
// Note: error handler is already added in makeDeviceConnection()
|
|
315
390
|
// Don't add duplicate error handler here
|
|
316
391
|
}
|
|
392
|
+
// socket.write() only queues bytes -- it does not guarantee they've reached the network yet.
|
|
393
|
+
// end() politely flushes-then-closes, but destroy() called right after it does a hard abort
|
|
394
|
+
// that discards anything still sitting in the send buffer. The local (loopback) leg of a
|
|
395
|
+
// request finishes near-instantly, well before the remote (real internet) leg has actually
|
|
396
|
+
// finished transmitting a large response, so destroying immediately after end() was silently
|
|
397
|
+
// truncating every larger response (e.g. min.js/min.css) at whatever had made it out before
|
|
398
|
+
// cleanup ran. Give end() a real chance to drain; only force-destroy as a fallback in case a
|
|
399
|
+
// peer never acknowledges, so a socket can't linger forever either.
|
|
400
|
+
gracefulEnd(socket) {
|
|
401
|
+
if (socket.destroyed)
|
|
402
|
+
return;
|
|
403
|
+
socket.removeAllListeners();
|
|
404
|
+
socket.end();
|
|
405
|
+
setTimeout(() => { if (!socket.destroyed)
|
|
406
|
+
socket.destroy(); }, 5000);
|
|
407
|
+
}
|
|
317
408
|
cleanupSockets() {
|
|
318
409
|
// Clean up the device socket
|
|
319
410
|
if (this.deviceSocket) {
|
|
320
|
-
|
|
321
|
-
this.deviceSocket.removeAllListeners();
|
|
322
|
-
this.deviceSocket.end();
|
|
323
|
-
this.deviceSocket.destroy();
|
|
324
|
-
}
|
|
411
|
+
this.gracefulEnd(this.deviceSocket);
|
|
325
412
|
this.deviceSocket = null;
|
|
326
413
|
}
|
|
327
414
|
// Clean up the cloud socket
|
|
328
415
|
if (this.cloudSocket) {
|
|
329
|
-
|
|
330
|
-
this.cloudSocket.removeAllListeners();
|
|
331
|
-
this.cloudSocket.end();
|
|
332
|
-
this.cloudSocket.destroy();
|
|
333
|
-
}
|
|
416
|
+
this.gracefulEnd(this.cloudSocket);
|
|
334
417
|
this.cloudSocket = null;
|
|
335
418
|
}
|
|
336
419
|
}
|
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")) {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* Page layout with sticky footer */
|
|
2
|
+
html, body { height: 100%; margin: 0; display: flex; flex-direction: column }
|
|
3
|
+
body { min-height: 100vh }
|
|
4
|
+
|
|
5
|
+
/* Main content area - grows to fill space */
|
|
6
|
+
body > *:not(nav):not(.page-footer) { flex: 1 0 auto }
|
|
7
|
+
|
|
8
|
+
form { padding-left: 7px; padding-right: 9px; padding-bottom: 20px }
|
|
9
|
+
form:last-of-type { padding-bottom: 80px }
|
|
10
|
+
|
|
11
|
+
nav, nav .nav-wrapper { height: 64px !important; line-height: 64px !important; flex-shrink: 0 }
|
|
12
|
+
nav .brand-logo { left: 10px !important; -webkit-transform: none !important; transform: none !important }
|
|
13
|
+
|
|
14
|
+
h1 { font-size: 30px }
|
|
15
|
+
h1.btn { margin-top: 18px }
|
|
16
|
+
h1 .btn { margin-left: 10px }
|
|
17
|
+
a span { margin-bottom: 7px; display: inline-block }
|
|
18
|
+
|
|
19
|
+
/* Sticky footer */
|
|
20
|
+
.page-footer { position: fixed; bottom: 0; left: 0; right: 0; width: 100%; flex-shrink: 0; z-index: 100; }
|
|
21
|
+
.page-footer p { text-align: right; padding-bottom: 4px; margin-right: 6px }
|
|
22
|
+
.page-footer a { float: left; margin-left: 10px; color: white; }
|
|
23
|
+
.page-footer { padding-top: 5px; padding-right: 6px }
|
|
24
|
+
.range-field { margin-bottom: -1em }
|
|
25
|
+
b { color: #ee6e73 }
|
|
26
|
+
.btn-small i { font-size: 1.1rem }
|
|
27
|
+
small { font-size: 70% }
|
|
28
|
+
.btn-floating.btn-small { width: 32px; height: 31px }
|
|
29
|
+
.rules p { display: inline-block; margin-bottom: 0px }
|
|
30
|
+
div.select-wrapper { max-width: 212px }
|
|
31
|
+
div.select-wrapper input.select-dropdown { font-size: 16px !important; height: 3rem !important; }
|
|
32
|
+
span.used { color: darkgrey }
|
|
33
|
+
.message { margin-left: 10px; font-size: 15px }
|
|
34
|
+
|
|
35
|
+
/* Hamburger menu icon - positioned on right side */
|
|
36
|
+
nav .sidenav-trigger {
|
|
37
|
+
position: absolute; right: 10px; top: 0;
|
|
38
|
+
height: 64px; line-height: 64px; padding: 0 15px; margin: 0;
|
|
39
|
+
}
|
|
40
|
+
nav .sidenav-trigger i { line-height: 64px; font-size: 2rem }
|
|
41
|
+
|
|
42
|
+
/* Hide hamburger on desktop, show desktop menu */
|
|
43
|
+
@media only screen and (min-width: 993px) {
|
|
44
|
+
nav .sidenav-trigger { display: none !important; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Show hamburger on mobile, hide desktop menu */
|
|
48
|
+
@media only screen and (max-width: 992px) {
|
|
49
|
+
nav .sidenav-trigger { display: block !important; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Desktop navigation active state */
|
|
53
|
+
nav ul li.active { background-color: rgba(0, 0, 0, 0.1) }
|
|
54
|
+
|
|
55
|
+
/* Sidenav styling */
|
|
56
|
+
.sidenav { width: 280px; }
|
|
57
|
+
.sidenav .user-view { padding: 1px 32px 16px 16px }
|
|
58
|
+
.sidenav .user-view .name { font-size: 18px; font-weight: 500; margin-top: 16px; display: block; }
|
|
59
|
+
.sidenav li > a { height: auto; line-height: 1px; padding: 14px 32px; }
|
|
60
|
+
.sidenav .divider { margin: 8px 0; }
|
|
61
|
+
.sidenav li.active { background-color: rgba(0,0,0,0.05); }
|
|
62
|
+
|
|
63
|
+
.brand-logo-svg { height: 46px; width: auto; display: block }
|
|
Binary file
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<body>
|
|
8
8
|
<%- include('/views/nav', this); -%>
|
|
9
9
|
|
|
10
|
-
<form method="POST" action="
|
|
10
|
+
<form method="POST" action="<%= basePath %>/devices">
|
|
11
11
|
<h1>
|
|
12
12
|
Devices
|
|
13
13
|
<button class="right btn waves-effect waves-light" type="submit" name="action" value="add">Add device</button>
|
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
<tbody>
|
|
24
24
|
<% for (let inx in devices) { let device = devices[inx]; %>
|
|
25
25
|
<tr>
|
|
26
|
-
<td><a href="
|
|
27
|
-
<td><a href="
|
|
28
|
-
<td><a href="
|
|
29
|
-
<td><a href="
|
|
26
|
+
<td><a href="<%= basePath %>/devices/edit/<%= inx %>"><%= device.name %></a></td>
|
|
27
|
+
<td><a href="<%= basePath %>/devices/edit/<%= inx %>"><%= device.id %></a></td>
|
|
28
|
+
<td><a href="<%= basePath %>/devices/edit/<%= inx %>"><%= device.unit?.name || "--" %></a></td>
|
|
29
|
+
<td><a href="<%= basePath %>/devices/edit/<%= inx %>"><%= hex(device.logicalNodeAddress) %>/<%= hex(device.logicalAddress) %></a></td>
|
|
30
30
|
<td><%= device.measured ? '✓' : '✗' %></td>
|
|
31
31
|
<td><%= device.relay ? 'ON' : 'OFF' %></td>
|
|
32
32
|
</tr>
|
package/server/views/footer.ejs
CHANGED
package/server/views/head.ejs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<meta charset="UTF-8">
|
|
2
|
-
<link type="text/css" rel="stylesheet" href="/files/min.css" media="screen,projection"/>
|
|
3
2
|
<title>Duotecno Gateway</title>
|
|
4
3
|
|
|
5
4
|
<!--Let browser know website is optimized for mobile-->
|
|
6
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
7
|
-
<link rel="shortcut icon" href="
|
|
8
|
-
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
|
6
|
+
<link rel="shortcut icon" href="<%= faviconDataUri %>" type="image/x-icon" />
|
|
9
7
|
|
|
10
|
-
|
|
8
|
+
<!-- Materialize CSS/JS, our layout CSS and the Material Icons webfont are all merged into
|
|
9
|
+
this one cached bundle, loaded blocking (not defer/async) so it can inject a <style>
|
|
10
|
+
element before the body is parsed -- keeps a page load down to 2 requests total. -->
|
|
11
|
+
<script src="<%= bundleUrl %>"></script>
|