homebridge-smartsystem 7.1.22 → 7.1.24

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
@@ -14,8 +14,22 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
14
14
  });
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.makeInt = exports.now = exports.datetime = exports.time = exports.date = exports.watt = exports.hex = exports.two = exports.cap = exports.char = exports.ascii = exports.wait = exports.Sanitizers = exports.actionValueStr = exports.actionValue = exports.kEmptyBinding = exports.kEmptyDevice = exports.PowerSource = exports.kEmptyLink = exports.LinkType = exports.kEmptySwitch = exports.SwitchType = exports.kEmptyRule = exports.kEmptyAction = exports.RuleType = exports.Boundaries = exports.kEmptyCommRecord = exports.WriteError = exports.kEmptySettings = exports.kEmptyNetworkConfig = exports.kEmptyGroup = exports.kEmptyScene = exports.kEmptyUnitScene = exports.kEmptyUnit = exports.UnitExtendedType = exports.UnitType = exports.UnitMotorCmd = exports.UnitState = exports.NodeType = void 0;
18
- const proxy_1 = require("../server/proxy");
17
+ exports.makeInt = exports.now = exports.datetime = exports.time = exports.date = exports.watt = exports.hex = exports.two = exports.cap = exports.char = exports.ascii = exports.wait = exports.Sanitizers = exports.actionValueStr = exports.actionValue = exports.kEmptyBinding = exports.kEmptyDevice = exports.PowerSource = exports.kEmptyLink = exports.LinkType = exports.kEmptySwitch = exports.SwitchType = exports.kEmptyRule = exports.kEmptyAction = exports.RuleType = exports.Boundaries = exports.kEmptyCommRecord = exports.WriteError = exports.kEmptySettings = exports.kEmptyNetworkConfig = exports.kEmptyGroup = exports.kEmptyScene = exports.kEmptyUnitScene = exports.kEmptyUnit = exports.UnitExtendedType = exports.UnitType = exports.UnitMotorCmd = exports.UnitState = exports.NodeType = exports.kEmptyProxy = void 0;
18
+ exports.kEmptyProxy = {
19
+ cloudServer: "masters.duotecno.eu",
20
+ cloudPort: 5097,
21
+ masterAddress: "",
22
+ masterPort: 5001,
23
+ masterConfigPort: 8080,
24
+ installationId: "",
25
+ nodeNumber: 0xff,
26
+ httpEnabled: false,
27
+ gatewayPhysicalId: "",
28
+ debug: false,
29
+ kind: "gw",
30
+ dserverEmail: "",
31
+ dserverPassword: "",
32
+ };
19
33
  // Node types
20
34
  var NodeType;
21
35
  (function (NodeType) {
@@ -419,16 +433,19 @@ exports.Sanitizers = {
419
433
  ///////////
420
434
  proxy: function (config, system) {
421
435
  var _a, _b;
422
- const c = Object.assign({}, proxy_1.kEmptyProxy);
423
- c.cloudServer = (config === null || config === void 0 ? void 0 : config.cloudServer) || proxy_1.kEmptyProxy.cloudServer;
424
- c.cloudPort = (config === null || config === void 0 ? void 0 : config.cloudPort) || proxy_1.kEmptyProxy.cloudPort;
436
+ const c = Object.assign({}, exports.kEmptyProxy);
437
+ c.cloudServer = (config === null || config === void 0 ? void 0 : config.cloudServer) || exports.kEmptyProxy.cloudServer;
438
+ c.cloudPort = (config === null || config === void 0 ? void 0 : config.cloudPort) || exports.kEmptyProxy.cloudPort;
425
439
  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
440
  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.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;
441
+ c.masterConfigPort = (config === null || config === void 0 ? void 0 : config.masterConfigPort) || exports.kEmptyProxy.masterConfigPort;
442
+ c.installationId = (config === null || config === void 0 ? void 0 : config.installationId) || exports.kEmptyProxy.installationId;
443
+ c.nodeNumber = (typeof (config === null || config === void 0 ? void 0 : config.nodeNumber) === "number") ? config.nodeNumber : exports.kEmptyProxy.nodeNumber;
430
444
  c.httpEnabled = !!(config === null || config === void 0 ? void 0 : config.httpEnabled);
445
+ c.gatewayPhysicalId = (config === null || config === void 0 ? void 0 : config.gatewayPhysicalId) || exports.kEmptyProxy.gatewayPhysicalId;
431
446
  c.kind = "gw";
447
+ c.dserverEmail = (config === null || config === void 0 ? void 0 : config.dserverEmail) || exports.kEmptyProxy.dserverEmail;
448
+ c.dserverPassword = (config === null || config === void 0 ? void 0 : config.dserverPassword) || exports.kEmptyProxy.dserverPassword;
432
449
  return c;
433
450
  },
434
451
  //////////////
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-smartsystem",
3
3
  "displayname": "Duotecno Bridge",
4
- "version": "7.1.22",
4
+ "version": "7.1.24",
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",
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ //
3
+ // Minimal client for dserver's Installation Backup API (see
4
+ // installation-backup-api.md in the dserver repo), used by the Proxy Config
5
+ // page's Backup/Restore buttons to push/pull this gateway's own config.json.
6
+ //
7
+ // dserver has no machine/device auth: a gateway authenticates exactly like
8
+ // a human user would (email+password -> rotating ticket), mirroring the
9
+ // Duotecno Designer app's DserverService.ts. The numeric installation id
10
+ // dserver needs is resolved from the same free-form string already used as
11
+ // ProxyConfig.installationId, via GET /identifier/{name} (HTTP Basic Auth
12
+ // only -- IdentifierController.isAllowed() never accepts a ticket there).
13
+ //
14
+ // Uses Node's built-in https module (not fetch) since this project's
15
+ // @types/node predates fetch/FormData/Blob typings.
16
+ //
17
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
18
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
19
+ return new (P || (P = Promise))(function (resolve, reject) {
20
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
21
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
22
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
23
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
24
+ });
25
+ };
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.downloadBackupFile = exports.createBackup = exports.getBackupDetail = exports.listBackups = exports.resolveInstallation = exports.clearDserverTicketCache = exports.kGatewayTag = void 0;
28
+ const https = require("https");
29
+ const kDserverHost = "duotecno.eu";
30
+ const kLang = "en";
31
+ exports.kGatewayTag = "gateway";
32
+ // rotating session ticket, cached in memory for the life of this process
33
+ let cachedTicket = null;
34
+ function clearDserverTicketCache() {
35
+ cachedTicket = null;
36
+ }
37
+ exports.clearDserverTicketCache = clearDserverTicketCache;
38
+ function basicAuthHeader(creds) {
39
+ return "Basic " + Buffer.from(`${creds.email}:${creds.password}`).toString("base64");
40
+ }
41
+ function rawRequest(options, body) {
42
+ return new Promise((resolve, reject) => {
43
+ const req = https.request(Object.assign({ host: kDserverHost }, options), res => {
44
+ const chunks = [];
45
+ res.on('data', (c) => chunks.push(c));
46
+ res.on('end', () => resolve({ status: res.statusCode || 0, body: Buffer.concat(chunks) }));
47
+ });
48
+ req.on('error', reject);
49
+ req.setTimeout(15000, () => req.destroy(new Error("dserver request timed out")));
50
+ if (body)
51
+ req.write(body);
52
+ req.end();
53
+ });
54
+ }
55
+ function parseJson(body) {
56
+ try {
57
+ return JSON.parse(body.toString('utf-8'));
58
+ }
59
+ catch (_a) {
60
+ return null;
61
+ }
62
+ }
63
+ function login(creds) {
64
+ var _a;
65
+ return __awaiter(this, void 0, void 0, function* () {
66
+ const body = Buffer.from(JSON.stringify({ request: "login", email: creds.email, password: creds.password }));
67
+ const { status, body: respBody } = yield rawRequest({
68
+ path: `/${kLang}/api`,
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json", "Content-Length": body.length }
71
+ }, body);
72
+ const json = parseJson(respBody);
73
+ if (status !== 200 || !json || String(json.status).toLowerCase() !== "ok" || !((_a = json.user) === null || _a === void 0 ? void 0 : _a.ticket)) {
74
+ throw new Error((json === null || json === void 0 ? void 0 : json.message) || `dserver login failed (HTTP ${status})`);
75
+ }
76
+ cachedTicket = json.user.ticket;
77
+ return cachedTicket;
78
+ });
79
+ }
80
+ function ensureTicket(creds) {
81
+ return __awaiter(this, void 0, void 0, function* () {
82
+ return cachedTicket || login(creds);
83
+ });
84
+ }
85
+ // Ticket-authenticated JSON call against /{lang}{path}, retrying once (with a forced fresh
86
+ // login) if the cached ticket turned out to be stale.
87
+ function call(creds, method, path, query = {}, body, extraHeaders = {}, retry = true) {
88
+ var _a;
89
+ return __awaiter(this, void 0, void 0, function* () {
90
+ const ticket = yield ensureTicket(creds);
91
+ const params = new URLSearchParams(Object.assign(Object.assign({}, query), { ticket }));
92
+ const headers = Object.assign({}, extraHeaders);
93
+ if (!body)
94
+ headers["Content-Type"] = "application/json"; // forces dserver's JSON (vs HTML) response branch
95
+ if (body)
96
+ headers["Content-Length"] = String(body.length);
97
+ const { status, body: respBody } = yield rawRequest({
98
+ path: `/${kLang}${path}?${params.toString()}`,
99
+ method,
100
+ headers
101
+ }, body);
102
+ const json = parseJson(respBody);
103
+ if (json === null || json === void 0 ? void 0 : json.ticket)
104
+ cachedTicket = json.ticket; // dserver rotates the ticket on every response
105
+ if (status === 403 && retry) {
106
+ cachedTicket = null;
107
+ return call(creds, method, path, query, body, extraHeaders, false);
108
+ }
109
+ if (!json || status < 200 || status >= 300 || ["nok", "err"].includes(String(json.status).toLowerCase())) {
110
+ throw new Error((json === null || json === void 0 ? void 0 : json.message) || `dserver call failed (HTTP ${status})`);
111
+ }
112
+ return (_a = json.data) !== null && _a !== void 0 ? _a : json;
113
+ });
114
+ }
115
+ // GET /{lang}/identifier/{name} -- the one endpoint that only accepts HTTP Basic Auth,
116
+ // never a ticket (see IdentifierController.isAllowed() in dserver).
117
+ function resolveInstallation(creds, name) {
118
+ return __awaiter(this, void 0, void 0, function* () {
119
+ const { status, body } = yield rawRequest({
120
+ path: `/${kLang}/identifier/${encodeURIComponent(name)}?nocookie=1`,
121
+ method: "GET",
122
+ headers: { "Authorization": basicAuthHeader(creds), "Content-Type": "application/json" }
123
+ });
124
+ const json = parseJson(body);
125
+ if (status !== 200 || !json || String(json.status).toLowerCase() !== "ok") {
126
+ throw new Error((json === null || json === void 0 ? void 0 : json.message) || `dserver installation lookup failed (HTTP ${status})`);
127
+ }
128
+ return { exists: !!json.exists, id: json.id, name };
129
+ });
130
+ }
131
+ exports.resolveInstallation = resolveInstallation;
132
+ function listBackups(creds, installationId, tag = exports.kGatewayTag) {
133
+ return call(creds, "GET", `/installation/${installationId}/backup`, { tag });
134
+ }
135
+ exports.listBackups = listBackups;
136
+ function getBackupDetail(creds, installationId, backupId) {
137
+ return call(creds, "GET", `/installation/${installationId}/backup/${backupId}`);
138
+ }
139
+ exports.getBackupDetail = getBackupDetail;
140
+ function createBackup(creds, installationId, description, filename, fileContent, tag = exports.kGatewayTag) {
141
+ const boundary = "----smartsocketBackup" + Date.now().toString(16);
142
+ const crlf = "\r\n";
143
+ const parts = [
144
+ Buffer.from(`--${boundary}${crlf}Content-Disposition: form-data; name="tag"${crlf}${crlf}${tag}${crlf}`),
145
+ ];
146
+ if (description) {
147
+ parts.push(Buffer.from(`--${boundary}${crlf}Content-Disposition: form-data; name="description"${crlf}${crlf}${description}${crlf}`));
148
+ }
149
+ parts.push(Buffer.from(`--${boundary}${crlf}Content-Disposition: form-data; name="file"; filename="${filename}"${crlf}Content-Type: application/json${crlf}${crlf}`));
150
+ parts.push(fileContent);
151
+ parts.push(Buffer.from(`${crlf}--${boundary}--${crlf}`));
152
+ const body = Buffer.concat(parts);
153
+ return call(creds, "POST", `/installation/${installationId}/backup`, {}, body, { "Content-Type": `multipart/form-data; boundary=${boundary}` });
154
+ }
155
+ exports.createBackup = createBackup;
156
+ function downloadBackupFile(creds, installationId, backupId, filename) {
157
+ return __awaiter(this, void 0, void 0, function* () {
158
+ const ticket = yield ensureTicket(creds);
159
+ // filename may contain "/" (a preserved subfolder path) -- encode each segment separately,
160
+ // matching dserver's own getFilename() which splits the URL path into segments itself.
161
+ const encodedPath = filename.split("/").map(encodeURIComponent).join("/");
162
+ const params = new URLSearchParams({ ticket });
163
+ const { status, body } = yield rawRequest({
164
+ path: `/${kLang}/installation/${installationId}/backup/${backupId}/files/${encodedPath}?${params.toString()}`,
165
+ method: "GET",
166
+ headers: { "Content-Type": "application/json" }
167
+ });
168
+ if (status !== 200) {
169
+ throw new Error(`dserver file download failed: ${filename} (HTTP ${status})`);
170
+ }
171
+ return body;
172
+ });
173
+ }
174
+ exports.downloadBackupFile = downloadBackupFile;
175
+ //# sourceMappingURL=dserver.js.map
package/server/proxy.js CHANGED
@@ -9,10 +9,14 @@ 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 = exports.gatewayHttpPort = exports.makeDeviceId = exports.kMasterNodeNumber = void 0;
12
+ exports.Context = exports.cleanStart = exports.makeNewCloudConnection = exports.gCloudConnections = exports.setProxyConfig = exports.setWebApp = exports.gatewayHttpPort = exports.makeDeviceId = exports.kMasterNodeNumber = exports.kEmptyProxy = 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
+ const types_1 = require("../duotecno/types");
19
+ Object.defineProperty(exports, "kEmptyProxy", { enumerable: true, get: function () { return types_1.kEmptyProxy; } });
16
20
  // Fixed, non-configurable logical node number every Duotecno master identifies itself with
17
21
  // on the bus -- used for both of the master's own registrations (raw protocol + Config API).
18
22
  exports.kMasterNodeNumber = 0xfc;
@@ -31,19 +35,48 @@ function gatewayHttpPort() {
31
35
  return port || 80;
32
36
  }
33
37
  exports.gatewayHttpPort = gatewayHttpPort;
34
- exports.kEmptyProxy = {
35
- cloudServer: "masters.duotecno.eu",
36
- cloudPort: 5097,
37
- masterAddress: "",
38
- masterPort: 5001,
39
- masterConfigPort: 8080,
40
- installationId: "",
41
- nodeNumber: 0xff,
42
- httpEnabled: false,
43
- debug: false,
44
- kind: "gw" // default running under the gateway or other process manager -> don't restart yourself
45
- };
46
- const config = Object.assign({}, exports.kEmptyProxy);
38
+ // The cloud server can tell apart two different physical devices that were accidentally
39
+ // registered under the same unique id (instead of assuming a reconnect and evicting the
40
+ // other one), given a "physical address" -- 1-8 hex digits, formatted "0x..." -- alongside
41
+ // the unique id at registration. Masters have a real one: the Duotecno bus physical address
42
+ // of their own logical node (kMasterNodeNumber). Returns '' if not known yet (e.g. the master
43
+ // hasn't finished its initial discovery), in which case no physical address is sent for this
44
+ // registration, same as old firmware.
45
+ function masterPhysicalAddress() {
46
+ const system = gWebApp === null || gWebApp === void 0 ? void 0 : gWebApp.system;
47
+ const master = (system === null || system === void 0 ? void 0 : system.findMaster) ? system.findMaster(config.masterAddress, config.masterPort) : null;
48
+ const node = (master === null || master === void 0 ? void 0 : master.findNode) ? master.findNode(exports.kMasterNodeNumber) : null;
49
+ const phys = node === null || node === void 0 ? void 0 : node.physicalAddress;
50
+ return (typeof phys === "number" && phys > 0) ? "0x" + phys.toString(16) : '';
51
+ }
52
+ // The gateway itself has no Duotecno bus address of its own. Prefer the last two groups of a
53
+ // non-internal IPv6 address (8 hex digits total, e.g. "2afa:23fa:b95:9ebf" -> last two groups
54
+ // "b95:9ebf" -> "0x0b959ebf") if one is available on this machine; otherwise fall back to a
55
+ // random id generated once and persisted in config.json, so it stays stable across restarts
56
+ // and lets the cloud tell two different physical gateways apart even if they were accidentally
57
+ // given the same installation id.
58
+ function gatewayPhysicalAddress() {
59
+ const interfaces = os.networkInterfaces();
60
+ for (const name in interfaces) {
61
+ for (const iface of interfaces[name] || []) {
62
+ if ((iface.family === "IPv6" || iface.family === 6) && !iface.internal) {
63
+ const groups = iface.address.split(":").filter(g => g.length > 0);
64
+ if (groups.length >= 2) {
65
+ const last2 = groups.slice(-2).map(g => g.padStart(4, '0')).join('');
66
+ return "0x" + last2;
67
+ }
68
+ }
69
+ }
70
+ }
71
+ if (!config.gatewayPhysicalId) {
72
+ config.gatewayPhysicalId = "0x" + Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, '0');
73
+ (0, config_1.setSubConfig)("proxy", config);
74
+ (0, config_1.setAPIConfig)();
75
+ log(`[PROXY] No IPv6 address available, generated and stored a random physical id for this gateway: ${config.gatewayPhysicalId}`);
76
+ }
77
+ return config.gatewayPhysicalId;
78
+ }
79
+ const config = Object.assign({}, types_1.kEmptyProxy);
47
80
  // Store reference to the webapp so we can close servers before restarting
48
81
  let gWebApp = null;
49
82
  function setWebApp(webapp) {
@@ -53,16 +86,19 @@ exports.setWebApp = setWebApp;
53
86
  function setProxyConfig(c) {
54
87
  // set the proxy config
55
88
  if (c) {
56
- config.cloudServer = c.cloudServer || exports.kEmptyProxy.cloudServer;
57
- config.cloudPort = c.cloudPort || exports.kEmptyProxy.cloudPort;
58
- config.masterAddress = c.masterAddress || exports.kEmptyProxy.masterAddress;
59
- config.masterPort = c.masterPort || exports.kEmptyProxy.masterPort;
60
- config.masterConfigPort = c.masterConfigPort || exports.kEmptyProxy.masterConfigPort;
61
- config.installationId = c.installationId || exports.kEmptyProxy.installationId;
62
- config.nodeNumber = (typeof c.nodeNumber === "number") ? c.nodeNumber : exports.kEmptyProxy.nodeNumber;
89
+ config.cloudServer = c.cloudServer || types_1.kEmptyProxy.cloudServer;
90
+ config.cloudPort = c.cloudPort || types_1.kEmptyProxy.cloudPort;
91
+ config.masterAddress = c.masterAddress || types_1.kEmptyProxy.masterAddress;
92
+ config.masterPort = c.masterPort || types_1.kEmptyProxy.masterPort;
93
+ config.masterConfigPort = c.masterConfigPort || types_1.kEmptyProxy.masterConfigPort;
94
+ config.installationId = c.installationId || types_1.kEmptyProxy.installationId;
95
+ config.nodeNumber = (typeof c.nodeNumber === "number") ? c.nodeNumber : types_1.kEmptyProxy.nodeNumber;
63
96
  config.httpEnabled = !!c.httpEnabled;
97
+ config.gatewayPhysicalId = c.gatewayPhysicalId || types_1.kEmptyProxy.gatewayPhysicalId;
64
98
  config.debug = !!c.debug;
65
- config.kind = c.kind || exports.kEmptyProxy.kind;
99
+ config.kind = c.kind || types_1.kEmptyProxy.kind;
100
+ config.dserverEmail = c.dserverEmail || types_1.kEmptyProxy.dserverEmail;
101
+ config.dserverPassword = c.dserverPassword || types_1.kEmptyProxy.dserverPassword;
66
102
  }
67
103
  return config;
68
104
  }
@@ -114,6 +150,9 @@ function makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId
114
150
  // we check every 16 seconds for a free connection, if none found -> restart
115
151
  }
116
152
  }
153
+ // lets the cloud tell two genuinely different physical devices apart if they were ever
154
+ // accidentally configured with the same unique id, instead of assuming a reconnect
155
+ const physicalAddress = isGateway ? gatewayPhysicalAddress() : masterPhysicalAddress();
117
156
  const cloudSocket = new net.Socket();
118
157
  cloudSocket.once('error', reconnect);
119
158
  // net.Socket.connect() has no default timeout: if the TCP handshake to the cloud gets
@@ -130,9 +169,9 @@ function makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId
130
169
  log(`[PROXY] → [CLOUD] Connected to Cloud at ${server}:${serverPort}`);
131
170
  cloudSocket.setTimeout(0); // connected -- no longer subject to the connect-attempt timeout
132
171
  cloudSocket.removeListener('error', reconnect);
133
- // Send unique ID as the first message to identify this proxy/device
134
- cloudSocket.write(`[${uniqueId}]`);
135
- debug(`[PROXY] → [CLOUD] Sent unique ID: ${uniqueId}`);
172
+ // Send unique ID (and physical address, if known) as the first message to identify this proxy/device
173
+ cloudSocket.write(physicalAddress ? `[${uniqueId}][${physicalAddress}]` : `[${uniqueId}]`);
174
+ debug(`[PROXY] → [CLOUD] Sent unique ID: ${uniqueId}` + (physicalAddress ? ` physical address: ${physicalAddress}` : ''));
136
175
  const context = new Context(cloudSocket, master, masterPort, server, serverPort, uniqueId, isGateway);
137
176
  exports.gCloudConnections.push(context);
138
177
  log(`[PROXY] → [CLOUD] New free connection #${exports.gCloudConnections.length} to the Cloud at ${server}:${serverPort}`);
@@ -29,6 +29,8 @@ const config_1 = require("../duotecno/config");
29
29
  const HB_1 = require("./HB");
30
30
  const HA_API_1 = require("./HA-API");
31
31
  const proxy_1 = require("./proxy");
32
+ const config_2 = require("../duotecno/config");
33
+ const dserver_1 = require("./dserver");
32
34
  const WebSocket = require("ws");
33
35
  const kMaster = { name: "master", type: "string", default: "0.0.0.0:5001" };
34
36
  const kAddress = { name: "address", type: "string", default: "0.0.0.0" };
@@ -377,6 +379,11 @@ function SmartSocketInit(message){
377
379
  doProxy(context) {
378
380
  return __awaiter(this, void 0, void 0, function* () {
379
381
  let config = this.read("proxy") || proxy_1.kEmptyProxy;
382
+ // "grey" (nothing configured yet), "blue" (installation id set, not checked against the
383
+ // cloud yet) or "warning" (a backup/restore attempt this request found a problem) -- see
384
+ // doBackupToCloud()/doRestoreFromCloud() below.
385
+ let backupState = config.installationId ? "blue" : "grey";
386
+ let backupMessage = "";
380
387
  if (context.action === "save") {
381
388
  // save the proxy config
382
389
  config.cloudServer = context.getParam({ name: "cloudServer", type: "string" });
@@ -388,14 +395,33 @@ function SmartSocketInit(message){
388
395
  config.nodeNumber = context.getParam({ name: "nodeNumber", type: "string" })
389
396
  ? parseInt(context.getParam({ name: "nodeNumber", type: "string" }), 16) : proxy_1.kEmptyProxy.nodeNumber;
390
397
  config.httpEnabled = !!context.getParam({ name: "httpEnabled", type: "string" });
398
+ config.dserverEmail = context.getParam({ name: "dserverEmail", type: "string" });
399
+ config.dserverPassword = context.getParam({ name: "dserverPassword", type: "string" });
391
400
  this.write("proxy", config);
392
401
  (0, proxy_1.setProxyConfig)(config);
393
402
  // Automatically restart proxy with new config
394
403
  (0, proxy_1.cleanStart)(true);
404
+ backupState = config.installationId ? "blue" : "grey";
395
405
  }
396
406
  else if (context.action === "restart") {
397
407
  (0, proxy_1.cleanStart)(true);
398
408
  }
409
+ else if (context.action === "backup") {
410
+ const result = yield this.doBackupToCloud(config);
411
+ backupState = result.state;
412
+ backupMessage = result.message;
413
+ }
414
+ else if (context.action === "restore") {
415
+ const backupId = parseInt(context.getParam({ name: "backupId", type: "string" }) || "", 10);
416
+ if (backupId) {
417
+ const result = yield this.doRestoreFromCloud(config, backupId);
418
+ if (result.ok) {
419
+ return this.doRestart(false); // process exits, gateway comes back up with the restored config
420
+ }
421
+ backupState = "warning";
422
+ backupMessage = result.message;
423
+ }
424
+ }
399
425
  else {
400
426
  // just show the proxy config
401
427
  }
@@ -412,7 +438,78 @@ function SmartSocketInit(message){
412
438
  configHttp: (0, proxy_1.makeDeviceId)(config, proxy_1.kMasterNodeNumber, "http", config.masterConfigPort || proxy_1.kEmptyProxy.masterConfigPort || 8080),
413
439
  http: (0, proxy_1.makeDeviceId)(config, config.nodeNumber, "http", (0, proxy_1.gatewayHttpPort)()),
414
440
  };
415
- return this.ejs("proxy", context, { config, connections: proxy_1.gCloudConnections, mdnsStatus, deviceIds });
441
+ // Best-effort: list existing "gateway"-tagged backups for display, so the page can show
442
+ // "N backup versions, last: ..." and offer a version to restore. Any failure here (no
443
+ // credentials yet, installation not registered, cloud unreachable) just shows an empty list
444
+ // rather than failing the whole page -- the Backup/Restore buttons already surface the same
445
+ // error more directly when actually clicked.
446
+ let backups = [];
447
+ let backupListError = "";
448
+ if (config.installationId && config.dserverEmail && config.dserverPassword) {
449
+ try {
450
+ const creds = { email: config.dserverEmail, password: config.dserverPassword };
451
+ const inst = yield (0, dserver_1.resolveInstallation)(creds, config.installationId);
452
+ if (inst.exists && inst.id) {
453
+ backups = yield (0, dserver_1.listBackups)(creds, inst.id);
454
+ }
455
+ }
456
+ catch (e) {
457
+ backupListError = e.message || String(e);
458
+ }
459
+ }
460
+ return this.ejs("proxy", context, { config, connections: proxy_1.gCloudConnections, mdnsStatus, deviceIds,
461
+ backupState, backupMessage, backups, backupListError });
462
+ });
463
+ }
464
+ // Pushes this gateway's own persisted config.json to dserver as a new "gateway"-tagged backup.
465
+ doBackupToCloud(config) {
466
+ return __awaiter(this, void 0, void 0, function* () {
467
+ if (!config.installationId) {
468
+ return { state: "grey", message: "" };
469
+ }
470
+ if (!config.dserverEmail || !config.dserverPassword) {
471
+ return { state: "warning", message: "No Duotecno account configured -- fill in the account email/password below and save first." };
472
+ }
473
+ const creds = { email: config.dserverEmail, password: config.dserverPassword };
474
+ try {
475
+ const inst = yield (0, dserver_1.resolveInstallation)(creds, config.installationId);
476
+ if (!inst.exists || !inst.id) {
477
+ return { state: "warning", message: `Installation "${config.installationId}" is not registered on the cloud server.` };
478
+ }
479
+ const fileContent = (0, fs_1.readFileSync)(config_2.kConfigFiles + "/config.json");
480
+ yield (0, dserver_1.createBackup)(creds, inst.id, `Gateway backup ${new Date().toISOString()}`, "config.json", fileContent);
481
+ return { state: "blue", message: "Backup created." };
482
+ }
483
+ catch (e) {
484
+ return { state: "warning", message: e.message || String(e) };
485
+ }
486
+ });
487
+ }
488
+ // Downloads the chosen "gateway"-tagged backup's config.json from dserver and overwrites this
489
+ // gateway's own persisted config.json with it -- caller is responsible for restarting after.
490
+ doRestoreFromCloud(config, backupId) {
491
+ return __awaiter(this, void 0, void 0, function* () {
492
+ if (!config.installationId || !config.dserverEmail || !config.dserverPassword) {
493
+ return { ok: false, message: "Missing installation ID or Duotecno account credentials." };
494
+ }
495
+ const creds = { email: config.dserverEmail, password: config.dserverPassword };
496
+ try {
497
+ const inst = yield (0, dserver_1.resolveInstallation)(creds, config.installationId);
498
+ if (!inst.exists || !inst.id) {
499
+ return { ok: false, message: `Installation "${config.installationId}" is not registered on the cloud server.` };
500
+ }
501
+ const detail = yield (0, dserver_1.getBackupDetail)(creds, inst.id, backupId);
502
+ const file = (detail.files || []).find(f => f.filename === "config.json" || f.path === "config.json");
503
+ if (!file) {
504
+ return { ok: false, message: "Selected backup does not contain a config.json file." };
505
+ }
506
+ const content = yield (0, dserver_1.downloadBackupFile)(creds, inst.id, backupId, file.path);
507
+ (0, fs_1.writeFileSync)(config_2.kConfigFiles + "/config.json", content);
508
+ return { ok: true, message: "Config restored." };
509
+ }
510
+ catch (e) {
511
+ return { ok: false, message: e.message || String(e) };
512
+ }
416
513
  });
417
514
  }
418
515
  //////////////
@@ -14,8 +14,56 @@
14
14
  <button class="right btn waves-effect waves-light" type="submit" name="action" value="cancel">Back</button>
15
15
  <button class="right btn waves-effect waves-light" type="submit" name="action" value="save">Save</button>
16
16
  <button class="right btn waves-effect waves-light red" type="submit" name="action" value="restart">Restart</button>
17
+ <button class="right btn waves-effect waves-light
18
+ <%= backupState === 'warning' ? 'orange darken-1' : (backupState === 'blue' ? 'blue' : 'grey') %>"
19
+ type="submit" name="action" value="backup" <%= backupState === 'grey' ? 'disabled' : '' %>>Backup</button>
17
20
  </h1>
18
21
 
22
+ <div class="row" style="clear: both; margin: 0 0 1rem 0;">
23
+ <div class="col s12">
24
+ <% // dd/mm/yyyy hh:mm:ss, per the backup versions list below
25
+ function fmtBackupDate(iso) {
26
+ const d = new Date(iso);
27
+ const two = (n) => (n < 10 ? "0" : "") + n;
28
+ return two(d.getDate()) + "/" + two(d.getMonth() + 1) + "/" + d.getFullYear() + " "
29
+ + two(d.getHours()) + ":" + two(d.getMinutes()) + ":" + two(d.getSeconds());
30
+ }
31
+ %>
32
+ <% if (backupMessage) { %>
33
+ <p class="<%= backupState === 'warning' ? 'orange-text text-darken-3' : 'green-text text-darken-3' %>" style="margin: 0 0 0.5rem 0;">
34
+ <%= backupMessage %>
35
+ </p>
36
+ <% } %>
37
+ <% if (backups && backups.length > 0) { %>
38
+ <p style="margin: 0 0 0.5rem 0;">
39
+ <%= backups.length %> backup version<%= backups.length === 1 ? "" : "s" %>, last:
40
+ <%= fmtBackupDate(backups[0].created) %>
41
+ </p>
42
+ <% backups.forEach(function(b, i) { %>
43
+ <p style="margin: 0;">
44
+ <label>
45
+ <input name="backupId" type="radio" class="with-gap" value="<%= b.id %>" <%= i === 0 ? "checked" : "" %> />
46
+ <span>
47
+ <%= fmtBackupDate(b.created) %>
48
+ <% if (b.description) { %>&mdash; <%= b.description %><% } %>
49
+ </span>
50
+ </label>
51
+ </p>
52
+ <% }); %>
53
+ <button class="btn waves-effect waves-light blue" type="submit" name="action" value="restore"
54
+ style="margin-top: 0.5rem;"
55
+ onclick="return confirm('This will overwrite the current gateway configuration with the selected backup and restart the gateway. Continue?');">
56
+ Restore
57
+ </button>
58
+ <% } else { %>
59
+ <p class="grey-text" style="margin: 0;">0 backup versions, last: -not yet-</p>
60
+ <% } %>
61
+ <% if (backupListError) { %>
62
+ <p class="red-text" style="margin: 0.5rem 0 0 0;"><small><%= backupListError %></small></p>
63
+ <% } %>
64
+ </div>
65
+ </div>
66
+
19
67
  <h2 style="margin-top: 2rem; font-size: 1.5rem; font-weight: 500;">
20
68
  <i class="material-icons left" style="vertical-align: middle;">cloud</i>
21
69
  Cloud Server Settings
@@ -60,6 +108,22 @@
60
108
  </div>
61
109
  </div>
62
110
 
111
+ <p class="grey-text" style="margin-bottom: 1rem;">
112
+ Duotecno account used by the <strong>Backup</strong>/<strong>Restore</strong> buttons below to push/pull
113
+ this gateway's config to/from the cloud server, tagged "gateway" -- the same account you'd use to log
114
+ into Duotecno Designer.
115
+ </p>
116
+ <div class="row" style="margin-top: 0;">
117
+ <div class="input-field col s6">
118
+ <label for="dserverEmail">Duotecno Account Email</label>
119
+ <input id="dserverEmail" name="dserverEmail" type="email" value="<%= config.dserverEmail || '' %>">
120
+ </div>
121
+ <div class="input-field col s6">
122
+ <label for="dserverPassword">Duotecno Account Password</label>
123
+ <input id="dserverPassword" name="dserverPassword" type="password" value="<%= config.dserverPassword || '' %>">
124
+ </div>
125
+ </div>
126
+
63
127
  <h2 style="margin-top: 3rem; font-size: 1.5rem; font-weight: 500;">
64
128
  <i class="material-icons left" style="vertical-align: middle;">home</i>
65
129
  Master Device