homebridge-smartsystem 7.1.20 → 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 +3 -2
- package/server/HB.js +19 -1
- package/server/platform.js +1 -1
- package/server/proxy.js +148 -65
- package/server/smartapp.js +106 -15
- 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",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"check-published": "npm view homebridge-smartsystem version",
|
|
17
17
|
"release:patch": "echo 'npm login !!' && git add -A && git commit -m 'pre-release' --allow-empty && npm version patch && npm publish && git push && git push --tags && echo 'Waiting for npm to propagate...' && sleep 5 && npm run check-published",
|
|
18
18
|
"release:minor": "echo 'npm login !!' && git add -A && git commit -m 'pre-release' --allow-empty && npm version minor && npm publish && git push && git push --tags && echo 'Waiting for npm to propagate...' && sleep 5 && npm run check-published",
|
|
19
|
-
"release:major": "git add -A && git commit -m 'pre-release' --allow-empty && npm version major && npm publish && git push && git push --tags && echo 'Waiting for npm to propagate...' && sleep 5 && npm run check-published"
|
|
19
|
+
"release:major": "git add -A && git commit -m 'pre-release' --allow-empty && npm version major && npm publish && git push && git push --tags && echo 'Waiting for npm to propagate...' && sleep 5 && npm run check-published",
|
|
20
|
+
"push:azure": "bash scripts/push-azure.sh master"
|
|
20
21
|
},
|
|
21
22
|
"engines": {
|
|
22
23
|
"node": "^18.20.4 || ^20.15.1 || ^22",
|
package/server/HB.js
CHANGED
|
@@ -9,11 +9,26 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.hbUnsubscribe = exports.hbSubscribe = exports.hbStop = exports.hbGetValue = exports.hbSetState = exports.dt2hbvalue = exports.hb2dtvalue = exports.hbLogin = exports.hbAccessories = exports.hbService = exports.hbName = void 0;
|
|
12
|
+
exports.hbUnsubscribe = exports.hbSubscribe = exports.hbStop = exports.hbGetValue = exports.hbSetState = exports.dt2hbvalue = exports.hb2dtvalue = exports.hbLogin = exports.hbAccessories = exports.hbService = exports.hbName = exports.HbAuthError = void 0;
|
|
13
13
|
const logger_1 = require("../duotecno/logger");
|
|
14
14
|
const HA_API_1 = require("./HA-API");
|
|
15
15
|
let gAccessories = [];
|
|
16
16
|
let timer = null;
|
|
17
|
+
// Thrown when the Homebridge API rejects a request because the access token is
|
|
18
|
+
// missing/expired, so callers can force a fresh hbLogin() instead of silently
|
|
19
|
+
// failing forever with the same stale token.
|
|
20
|
+
class HbAuthError extends Error {
|
|
21
|
+
constructor(message = "Homebridge API rejected the request: token expired?") {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "HbAuthError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
exports.HbAuthError = HbAuthError;
|
|
27
|
+
function checkHbAuth(respBody) {
|
|
28
|
+
if (respBody && (respBody.statusCode === 401 || /unauthorized/i.test(respBody.message || respBody.error || ""))) {
|
|
29
|
+
throw new HbAuthError();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
17
32
|
function hbName(id) {
|
|
18
33
|
var _a;
|
|
19
34
|
return ((_a = gAccessories.find(item => item.id === id)) === null || _a === void 0 ? void 0 : _a.name) || "Unknown";
|
|
@@ -265,6 +280,7 @@ function hbSetState(server, id, service, value, token) {
|
|
|
265
280
|
const respBody = yield (0, HA_API_1.httpRequest)(server + "/api/accessories/" + id, 'PUT', JSON.stringify(data), HA_API_1.ContentType.json, {
|
|
266
281
|
Authorization: 'Bearer ' + token
|
|
267
282
|
});
|
|
283
|
+
checkHbAuth(respBody);
|
|
268
284
|
if (respBody && respBody.error) {
|
|
269
285
|
(0, logger_1.log)("HB", "hbSetState: " + id + " -> error: " + JSON.stringify(respBody));
|
|
270
286
|
}
|
|
@@ -277,6 +293,7 @@ function hbGetValue(server, id, service, token) {
|
|
|
277
293
|
const respBody = yield (0, HA_API_1.httpRequest)(server + "/api/accessories/" + id, 'GET', "", HA_API_1.ContentType.json, {
|
|
278
294
|
Authorization: 'Bearer ' + token
|
|
279
295
|
});
|
|
296
|
+
checkHbAuth(respBody);
|
|
280
297
|
if (respBody && respBody.error) {
|
|
281
298
|
(0, logger_1.log)("HB", "hbGetState: " + id + " -> error: " + JSON.stringify(respBody));
|
|
282
299
|
return undefined;
|
|
@@ -298,6 +315,7 @@ function hbStop(server, id, service, token) {
|
|
|
298
315
|
const respBody = yield (0, HA_API_1.httpRequest)(server + "/api/accessories/" + id, 'GET', "", HA_API_1.ContentType.json, {
|
|
299
316
|
Authorization: 'Bearer ' + token
|
|
300
317
|
});
|
|
318
|
+
checkHbAuth(respBody);
|
|
301
319
|
if (respBody && respBody.error) {
|
|
302
320
|
(0, logger_1.log)("HB", "hbGetState: " + id + " -> error: " + JSON.stringify(respBody));
|
|
303
321
|
return undefined;
|
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
|
//////////////
|
|
@@ -532,17 +591,30 @@ class SmartApp extends webapp_1.WebApp {
|
|
|
532
591
|
if (this.links.length && (yield this.checkToken())) {
|
|
533
592
|
for (const link of this.links) {
|
|
534
593
|
if ((link.type === types_1.LinkType.kAccessory) && link.unit) {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
594
|
+
try {
|
|
595
|
+
const hbValue = yield (0, HB_1.hbGetValue)(this.config.apiHost + ":" + this.config.apiPort, link.accId, link.accService, this.token);
|
|
596
|
+
(0, logger_1.debug)("smartapp", "Received HB value for accessory: " + link.accName + " -> " + hbValue + " from accessory");
|
|
597
|
+
if (typeof hbValue != "undefined") {
|
|
598
|
+
const { dtValue, dtTarget } = (0, HB_1.hb2dtvalue)(link.unit, hbValue);
|
|
599
|
+
if (dtValue !== link.dtValue) { // || state !== link.status
|
|
600
|
+
(0, logger_1.log)("smartapp", "-> Setting value of Duotecno unit: " + link.unit.getDisplayName() + " to " + dtTarget + " of HB accessory: " + link.accName + " with value " + hbValue);
|
|
601
|
+
link.dtValue = dtValue;
|
|
602
|
+
link.unit.setState(dtTarget);
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
(0, logger_1.debug)("smartapp", "-> No change for unit: " + link.unit.getDisplayName() + " to " + dtValue);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (e) {
|
|
610
|
+
if (e instanceof HB_1.HbAuthError) {
|
|
611
|
+
(0, logger_1.err)("smartapp", "initLinks: Homebridge API token expired -> forcing re-login on next round");
|
|
612
|
+
this.token = "";
|
|
613
|
+
(0, config_1.setToken)("");
|
|
614
|
+
break;
|
|
543
615
|
}
|
|
544
616
|
else {
|
|
545
|
-
(0, logger_1.
|
|
617
|
+
(0, logger_1.err)("smartapp", "initLinks: failed to get HB value for accessory " + link.accName + " -> " + e.toString());
|
|
546
618
|
}
|
|
547
619
|
}
|
|
548
620
|
}
|
|
@@ -553,6 +625,10 @@ class SmartApp extends webapp_1.WebApp {
|
|
|
553
625
|
informLinks(u, kind) {
|
|
554
626
|
return __awaiter(this, void 0, void 0, function* () {
|
|
555
627
|
(0, logger_1.debug)("smartapp", "Received DT value from unit: " + u.getDisplayName() + " -> " + u.value + " / " + u.status + " from unit");
|
|
628
|
+
if (!this.links.length)
|
|
629
|
+
return;
|
|
630
|
+
if (!(yield this.checkToken()))
|
|
631
|
+
return;
|
|
556
632
|
for (const link of this.links) {
|
|
557
633
|
if (u.isUnit(link.masterAddress, link.masterPort, link.logicalNodeAddress, link.logicalAddress)) {
|
|
558
634
|
const hbValue = (0, HB_1.dt2hbvalue)(u, link.hbValue, link.min, link.max);
|
|
@@ -574,7 +650,14 @@ class SmartApp extends webapp_1.WebApp {
|
|
|
574
650
|
}
|
|
575
651
|
}
|
|
576
652
|
catch (e) {
|
|
577
|
-
|
|
653
|
+
if (e instanceof HB_1.HbAuthError) {
|
|
654
|
+
(0, logger_1.err)("smartapp", "informLinks: Homebridge API token expired for accessory " + link.accName + " -> forcing re-login on next update");
|
|
655
|
+
this.token = "";
|
|
656
|
+
(0, config_1.setToken)("");
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
(0, logger_1.err)("smartapp", "informLinks: failed to set HB value for accessory " + link.accName + " -> " + e.toString());
|
|
660
|
+
}
|
|
578
661
|
}
|
|
579
662
|
}
|
|
580
663
|
}
|
|
@@ -1539,7 +1622,15 @@ class SmartApp extends webapp_1.WebApp {
|
|
|
1539
1622
|
//////////////////////////////
|
|
1540
1623
|
renderAssets(context) {
|
|
1541
1624
|
const file = context.action;
|
|
1542
|
-
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")) {
|
|
1543
1634
|
return this.file("materializeCSS");
|
|
1544
1635
|
}
|
|
1545
1636
|
else if ((file === "min.js") || (file === "materialize.min.js") || (file === "materialize.js")) {
|