homebridge-smartsystem 7.1.23 → 7.1.25

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
@@ -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) {
@@ -154,7 +168,7 @@ var LinkType;
154
168
  LinkType["kNoType"] = "";
155
169
  LinkType["kAccessory"] = "accessory";
156
170
  })(LinkType = exports.LinkType || (exports.LinkType = {}));
157
- exports.kEmptyLink = Object.assign(Object.assign({}, exports.kEmptyUnit), { type: LinkType.kNoType, max: 100, min: 0, accName: "acc", accId: "", accService: [] });
171
+ exports.kEmptyLink = Object.assign(Object.assign({}, exports.kEmptyUnit), { type: LinkType.kNoType, max: 100, min: 0, nostop: "N", accName: "acc", accId: "", accService: [] });
158
172
  /////////////
159
173
  // Devices //
160
174
  /////////////
@@ -328,6 +342,7 @@ exports.Sanitizers = {
328
342
  link.type = aLink.type || exports.kEmptyLink.type;
329
343
  link.max = aLink.max || exports.kEmptyLink.max;
330
344
  link.min = aLink.min || exports.kEmptyLink.min;
345
+ link.nostop = aLink.nostop || exports.kEmptyLink.nostop;
331
346
  return link;
332
347
  },
333
348
  deviceConfig: function (aDevice) {
@@ -386,7 +401,8 @@ exports.Sanitizers = {
386
401
  return exports.Sanitizers.linkConfig({ name: aLink.name, unitName: aLink.unitName,
387
402
  masterAddress: aLink.masterAddress, masterPort: aLink.masterPort,
388
403
  logicalAddress: aLink.logicalAddress, logicalNodeAddress: aLink.logicalNodeAddress,
389
- accName: aLink.accName, accService: aLink.accService, accId: aLink.accId, type: aLink.type });
404
+ accName: aLink.accName, accService: aLink.accService, accId: aLink.accId, type: aLink.type,
405
+ max: aLink.max, min: aLink.min, nostop: aLink.nostop });
390
406
  },
391
407
  ruleConfig(rule) {
392
408
  rule = Object.assign({}, rule);
@@ -419,17 +435,19 @@ exports.Sanitizers = {
419
435
  ///////////
420
436
  proxy: function (config, system) {
421
437
  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;
438
+ const c = Object.assign({}, exports.kEmptyProxy);
439
+ c.cloudServer = (config === null || config === void 0 ? void 0 : config.cloudServer) || exports.kEmptyProxy.cloudServer;
440
+ c.cloudPort = (config === null || config === void 0 ? void 0 : config.cloudPort) || exports.kEmptyProxy.cloudPort;
425
441
  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
442
  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;
443
+ c.masterConfigPort = (config === null || config === void 0 ? void 0 : config.masterConfigPort) || exports.kEmptyProxy.masterConfigPort;
444
+ c.installationId = (config === null || config === void 0 ? void 0 : config.installationId) || exports.kEmptyProxy.installationId;
445
+ c.nodeNumber = (typeof (config === null || config === void 0 ? void 0 : config.nodeNumber) === "number") ? config.nodeNumber : exports.kEmptyProxy.nodeNumber;
430
446
  c.httpEnabled = !!(config === null || config === void 0 ? void 0 : config.httpEnabled);
431
- c.gatewayPhysicalId = (config === null || config === void 0 ? void 0 : config.gatewayPhysicalId) || proxy_1.kEmptyProxy.gatewayPhysicalId;
447
+ c.gatewayPhysicalId = (config === null || config === void 0 ? void 0 : config.gatewayPhysicalId) || exports.kEmptyProxy.gatewayPhysicalId;
432
448
  c.kind = "gw";
449
+ c.dserverEmail = (config === null || config === void 0 ? void 0 : config.dserverEmail) || exports.kEmptyProxy.dserverEmail;
450
+ c.dserverPassword = (config === null || config === void 0 ? void 0 : config.dserverPassword) || exports.kEmptyProxy.dserverPassword;
433
451
  return c;
434
452
  },
435
453
  //////////////
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-smartsystem",
3
3
  "displayname": "Duotecno Bridge",
4
- "version": "7.1.23",
4
+ "version": "7.1.25",
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",
@@ -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,12 +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
14
  const os = require("os");
15
15
  const child_process_1 = require("child_process");
16
16
  const process_1 = require("process");
17
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; } });
18
20
  // Fixed, non-configurable logical node number every Duotecno master identifies itself with
19
21
  // on the bus -- used for both of the master's own registrations (raw protocol + Config API).
20
22
  exports.kMasterNodeNumber = 0xfc;
@@ -74,20 +76,7 @@ function gatewayPhysicalAddress() {
74
76
  }
75
77
  return config.gatewayPhysicalId;
76
78
  }
77
- exports.kEmptyProxy = {
78
- cloudServer: "masters.duotecno.eu",
79
- cloudPort: 5097,
80
- masterAddress: "",
81
- masterPort: 5001,
82
- masterConfigPort: 8080,
83
- installationId: "",
84
- nodeNumber: 0xff,
85
- httpEnabled: false,
86
- gatewayPhysicalId: "",
87
- debug: false,
88
- kind: "gw" // default running under the gateway or other process manager -> don't restart yourself
89
- };
90
- const config = Object.assign({}, exports.kEmptyProxy);
79
+ const config = Object.assign({}, types_1.kEmptyProxy);
91
80
  // Store reference to the webapp so we can close servers before restarting
92
81
  let gWebApp = null;
93
82
  function setWebApp(webapp) {
@@ -97,17 +86,19 @@ exports.setWebApp = setWebApp;
97
86
  function setProxyConfig(c) {
98
87
  // set the proxy config
99
88
  if (c) {
100
- config.cloudServer = c.cloudServer || exports.kEmptyProxy.cloudServer;
101
- config.cloudPort = c.cloudPort || exports.kEmptyProxy.cloudPort;
102
- config.masterAddress = c.masterAddress || exports.kEmptyProxy.masterAddress;
103
- config.masterPort = c.masterPort || exports.kEmptyProxy.masterPort;
104
- config.masterConfigPort = c.masterConfigPort || exports.kEmptyProxy.masterConfigPort;
105
- config.installationId = c.installationId || exports.kEmptyProxy.installationId;
106
- 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;
107
96
  config.httpEnabled = !!c.httpEnabled;
108
- config.gatewayPhysicalId = c.gatewayPhysicalId || exports.kEmptyProxy.gatewayPhysicalId;
97
+ config.gatewayPhysicalId = c.gatewayPhysicalId || types_1.kEmptyProxy.gatewayPhysicalId;
109
98
  config.debug = !!c.debug;
110
- 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;
111
102
  }
112
103
  return config;
113
104
  }
@@ -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
  //////////////
@@ -635,10 +732,15 @@ function SmartSocketInit(message){
635
732
  if (typeof hbValue != "undefined") {
636
733
  try {
637
734
  if (hbValue === -1) {
638
- (0, logger_1.log)("smartapp", "-> Virtual unit send 'stop' for accessory: " + link.accName + " of Duotecno unit: " + u.getDisplayName());
639
- const val = yield (0, HB_1.hbStop)(this.config.apiHost + ":" + this.config.apiPort, link.accId, link.accService, this.token);
640
- if (typeof val != "undefined")
641
- link.hbValue = val;
735
+ if (link.nostop === "Y") {
736
+ (0, logger_1.debug)("smartapp", "-> Not sending simulated 'stop' for accessory: " + link.accName + " of Duotecno unit: " + u.getDisplayName() + " (disabled)");
737
+ }
738
+ else {
739
+ (0, logger_1.log)("smartapp", "-> Virtual unit send 'stop' for accessory: " + link.accName + " of Duotecno unit: " + u.getDisplayName());
740
+ const val = yield (0, HB_1.hbStop)(this.config.apiHost + ":" + this.config.apiPort, link.accId, link.accService, this.token);
741
+ if (typeof val != "undefined")
742
+ link.hbValue = val;
743
+ }
642
744
  }
643
745
  else if (hbValue !== link.hbValue) {
644
746
  (0, logger_1.log)("smartapp", "-> Setting HB value for accessory: " + link.accName + " to " + hbValue + " of Duotecno unit: " + u.getDisplayName() + " with value " + u.value);
@@ -730,11 +832,13 @@ function SmartSocketInit(message){
730
832
  // max & min values
731
833
  const min = context.getParam({ name: "min", type: "integer", default: 0 });
732
834
  const max = context.getParam({ name: "max", type: "integer", default: 100 });
835
+ // don't forward the simulated 'stop' of an up/down link to the accessory
836
+ const nostop = context.getParam({ name: "nostop", type: "string", default: "N" });
733
837
  // uniqiue id for the accessory
734
838
  const accId = context.getParam({ name: "accId", type: "string", default: "" });
735
839
  const accName = (0, HB_1.hbName)(accId);
736
840
  const accService = (0, HB_1.hbService)(accId);
737
- return { accId, accName, accService, name, masterAddress, masterPort, logicalAddress, logicalNodeAddress, type: stype, min, max };
841
+ return { accId, accName, accService, name, masterAddress, masterPort, logicalAddress, logicalNodeAddress, type: stype, min, max, nostop };
738
842
  ;
739
843
  }
740
844
  updateLink(inx, link) {
@@ -74,6 +74,17 @@
74
74
  </div>
75
75
  </div>
76
76
 
77
+ <% if (link.unit && link.unit.isUpDown()) { %>
78
+ <div class="row">
79
+ <div class="switch input-field col s4">
80
+ <label>Send stop
81
+ <input id="nostop" name="nostop" <%= (link.nostop == 'Y') ? 'checked' : '' %> type="checkbox" value="Y">
82
+ <span class="lever"></span>Don't
83
+ </label>
84
+ </div>
85
+ </div>
86
+ <% } %>
87
+
77
88
  <div class="row">
78
89
  <div class="input-field col s4">
79
90
  <label for="min">Off value</label>
@@ -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
@@ -1 +0,0 @@
1
- {"sessionId":"29e28b48-3a89-4159-baf0-58e668c1fe0d","pid":61331,"procStart":"Wed Sep 16 12:58:32 2026","acquiredAt":1789563711220}