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.
@@ -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
  }
@@ -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.uniqueId = (config === null || config === void 0 ? void 0 : config.uniqueId) || proxy_1.kEmptyProxy.uniqueId;
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.21",
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",
@@ -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.uniqueId)) {
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
- uniqueId: "",
24
- configUniqueId: "",
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.uniqueId = c.uniqueId || exports.kEmptyProxy.uniqueId;
45
- config.configUniqueId = c.configUniqueId || exports.kEmptyProxy.configUniqueId;
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
- log(`[PROXY] → [CLOUD] Attempt ${count} to start a new free connection for ${config.uniqueId} to the Cloud at ${server}:${serverPort}`);
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 uniqueId is configured
131
- if (config.uniqueId && config.masterAddress) {
132
- const masterId = `${config.uniqueId}:${config.masterPort}`;
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.uniqueId) {
138
- log(`[PROXY] → [CLOUD] Master uniqueId configured but no masterAddress, skipping master connection.`);
215
+ else if (config.installationId) {
216
+ log(`[PROXY] → [CLOUD] Installation ID configured but no masterAddress, skipping master connection.`);
139
217
  }
140
- // Start TCP proxy for Config API if masterConfigPort is configured
141
- // Use uniqueId:8080 pattern (or configUniqueId if explicitly set for backward compatibility)
142
- if (config.uniqueId && config.masterAddress && config.masterConfigPort) {
143
- const configId = config.configUniqueId || `${config.uniqueId}:${config.masterConfigPort}`;
144
- log(`[PROXY] Starting TCP proxy for Config API: ${configId} -> ${config.masterAddress}:${config.masterConfigPort}`);
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 have no master address (they handle HTTP, not TCP proxy)
182
- this.isGateway = !master || master === "";
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
- if (this.deviceSocket) {
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
- if (this.deviceSocket) {
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
- if (this.isGateway) {
230
- // Gateway connection - forward HTTP request to local HTTP gateway on port 5002
231
- log(`[PROXY] → [CLOUD] New client connection for gateway: ${this.uniqueId}.`);
232
- makeNewCloudConnection(this.master, this.masterPort, this.server, this.serverPort, this.uniqueId);
233
- this.makeDeviceConnection("localhost", 5002, data);
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
- warning(`[PROXY] → [DEVICE] Device socket is not open yet, waiting for connection... what to do with the data??`);
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
- log(`[DEVICE] → [PROXY] Connection failed. Will retry on next cloud request.`);
266
- // Clean up this socket
267
- if (this.deviceSocket && !this.deviceSocket.destroyed) {
268
- this.deviceSocket.destroy();
269
- }
270
- this.deviceSocket = null;
271
- // Don't create a new cloud connection - we already have one!
272
- // The device connection will be retried when the next data comes from the cloud
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
- if (!this.deviceSocket.destroyed) {
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
- if (!this.cloudSocket.destroyed) {
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
  }
@@ -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.uniqueId = context.getParam({ name: "uniqueId", type: "string" });
338
- config.configUniqueId = context.getParam({ name: "configUniqueId", type: "string" });
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
- return this.ejs("proxy", context, { config, connections: proxy_1.gCloudConnections, mdnsStatus });
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 ((file === "min.css") || (file === "materialize.min.css") || (file === "materialize.css")) {
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")) {