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 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.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);
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.21",
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",
@@ -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,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
- uniqueId: "",
24
- configUniqueId: "",
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.uniqueId = c.uniqueId || exports.kEmptyProxy.uniqueId;
45
- config.configUniqueId = c.configUniqueId || exports.kEmptyProxy.configUniqueId;
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
- log(`[PROXY] → [CLOUD] Attempt ${count} to start a new free connection for ${config.uniqueId} to the Cloud at ${server}:${serverPort}`);
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 uniqueId is configured
131
- if (config.uniqueId && config.masterAddress) {
132
- const masterId = `${config.uniqueId}:${config.masterPort}`;
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.uniqueId) {
138
- log(`[PROXY] → [CLOUD] Master uniqueId configured but no masterAddress, skipping master connection.`);
167
+ else if (config.installationId) {
168
+ log(`[PROXY] → [CLOUD] Installation ID configured but no masterAddress, skipping master connection.`);
139
169
  }
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}`);
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 have no master address (they handle HTTP, not TCP proxy)
182
- this.isGateway = !master || master === "";
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
- if (this.deviceSocket) {
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
- if (this.deviceSocket) {
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
- 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
- }
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
- warning(`[PROXY] → [DEVICE] Device socket is not open yet, waiting for connection... what to do with the data??`);
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
- 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
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
- if (!this.deviceSocket.destroyed) {
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
- if (!this.cloudSocket.destroyed) {
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
  }
@@ -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")) {
@@ -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 }
@@ -7,7 +7,7 @@
7
7
  <body>
8
8
  <%- include('/views/nav', this); -%>
9
9
 
10
- <form method="POST" action="/devices">
10
+ <form method="POST" action="<%= basePath %>/devices">
11
11
  <input type="hidden" name="daction" value="save">
12
12
  <input type="hidden" name="id" value="<%= id %>">
13
13
  <h1>
@@ -7,7 +7,7 @@
7
7
  <body>
8
8
  <%- include('/views/nav', this); -%>
9
9
 
10
- <form method="POST" action="/devices">
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="/devices/edit/<%= inx %>"><%= device.name %></a></td>
27
- <td><a href="/devices/edit/<%= inx %>"><%= device.id %></a></td>
28
- <td><a href="/devices/edit/<%= inx %>"><%= device.unit?.name || "--" %></a></td>
29
- <td><a href="/devices/edit/<%= inx %>"><%= hex(device.logicalNodeAddress) %>/<%= hex(device.logicalAddress) %></a></td>
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>
@@ -1,6 +1,6 @@
1
1
  <footer class="page-footer">
2
2
  <p>
3
- <a href="/login/logout">Log out</a>
3
+ <a href="<%= basePath %>/login/logout">Log out</a>
4
4
  Johan Coppieters - Duotecno, 2019-<%= year %> - v<%= version %>
5
5
  </p>
6
6
 
@@ -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="/files/favicon.ico" type="image/x-icon" />
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
- <%- include('/views/style'); -%>
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>