@smarterhomeapp/homebridge-vantage 1.0.5 → 1.0.7
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/README.md +12 -3
- package/dist/platform.d.ts +2 -0
- package/dist/platform.js +37 -2
- package/dist/vantageInfusion.d.ts +13 -1
- package/dist/vantageInfusion.js +220 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ This plugin replaces the one sold on smarterhome.io allowing anyone to download
|
|
|
12
12
|
- **Real-time Updates**: Live status updates from your InFusion controller
|
|
13
13
|
- **SSL Support**: Secure connections to your controller
|
|
14
14
|
- **Device Filtering**: Include/exclude specific devices by VID range
|
|
15
|
+
- **Safe Configuration Cache**: Downloads fresh controller configuration on every startup while keeping a persistent last-known-good fallback
|
|
15
16
|
|
|
16
17
|
## Installation
|
|
17
18
|
|
|
@@ -52,10 +53,18 @@ npm install -g @smarterhomeapp/homebridge-vantage
|
|
|
52
53
|
| `ipaddress` | string | Yes | IP address of your InFusion controller |
|
|
53
54
|
| `username` | string | No | Username for authentication |
|
|
54
55
|
| `password` | string | No | Password for authentication |
|
|
55
|
-
| `usecache` | boolean | No |
|
|
56
|
+
| `usecache` | boolean | No | Maintain a persistent last-known-good configuration fallback while still attempting fresh controller discovery on every startup (default: true) |
|
|
56
57
|
| `omit` | string | No | Comma-separated list of VIDs to exclude |
|
|
57
58
|
| `range` | string | No | Comma-separated VID range (min,max) |
|
|
58
59
|
|
|
60
|
+
## Configuration Discovery and Cache
|
|
61
|
+
|
|
62
|
+
On each Homebridge startup, the plugin first attempts to download the current Vantage controller configuration. If the downloaded configuration is valid and complete, it is used immediately and saved atomically as the persistent last-known-good configuration.
|
|
63
|
+
|
|
64
|
+
When `usecache` is enabled, the persistent cache is only a fallback. If the controller is offline, returns malformed XML, returns an empty configuration, or returns a suspiciously incomplete configuration, the plugin preserves the existing cache and uses the last-known-good configuration instead.
|
|
65
|
+
|
|
66
|
+
The cache is stored under Homebridge's configured storage directory in a plugin-specific `vantage` folder. Cache filenames are keyed by controller IP, for example `vantage-10.0.0.100.dc`, so multiple platform instances using different VID ranges for the same controller share one full-controller cache. Restarts naturally pick up controller/DC changes; users do not need to manually clear the cache.
|
|
67
|
+
|
|
59
68
|
## Supported Devices
|
|
60
69
|
|
|
61
70
|
### Lighting
|
|
@@ -92,7 +101,7 @@ npm run build
|
|
|
92
101
|
|
|
93
102
|
4. Link for development:
|
|
94
103
|
```bash
|
|
95
|
-
npm
|
|
104
|
+
npm link
|
|
96
105
|
```
|
|
97
106
|
|
|
98
107
|
## Troubleshooting
|
|
@@ -109,7 +118,7 @@ npm run dev
|
|
|
109
118
|
|
|
110
119
|
### Performance Issues
|
|
111
120
|
- Reduce the number of devices by using the `omit` parameter
|
|
112
|
-
-
|
|
121
|
+
- Restart Homebridge to force a fresh controller configuration download
|
|
113
122
|
- Consider using device ranges to limit discovery
|
|
114
123
|
|
|
115
124
|
## License
|
package/dist/platform.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export declare class VantagePlatform implements DynamicPlatformPlugin {
|
|
|
12
12
|
constructor(log: Logger, config: PlatformConfig, api: API);
|
|
13
13
|
configureAccessory(accessory: PlatformAccessory): void;
|
|
14
14
|
private syncAccessories;
|
|
15
|
+
private shouldSkipStaleRemoval;
|
|
16
|
+
private rebuildAccessoryVidMap;
|
|
15
17
|
private wireRealtimeListeners;
|
|
16
18
|
private finalizeLogs;
|
|
17
19
|
getInfusion(): VantageInfusion;
|
package/dist/platform.js
CHANGED
|
@@ -26,6 +26,7 @@ class VantagePlatform {
|
|
|
26
26
|
omit: config.omit ?? '',
|
|
27
27
|
range: config.range ?? '0,999999999',
|
|
28
28
|
forceSSL: config.forceSSL ?? false,
|
|
29
|
+
storagePath: this.api.user.storagePath(),
|
|
29
30
|
log: this.log,
|
|
30
31
|
});
|
|
31
32
|
// ----- Standard HB boot path -----
|
|
@@ -50,10 +51,27 @@ class VantagePlatform {
|
|
|
50
51
|
// Reconcile cached accessories with current device list
|
|
51
52
|
async syncAccessories(devices) {
|
|
52
53
|
const uuidFor = (d) => this.api.hap.uuid.generate(String(d.vid));
|
|
54
|
+
if (devices.length === 0 && this.accessories.length > 0) {
|
|
55
|
+
this.log.error(`Discovery returned 0 devices; preserving ${this.accessories.length} cached accessories ` +
|
|
56
|
+
'and rebuilding realtime VID map.');
|
|
57
|
+
this.rebuildAccessoryVidMap();
|
|
58
|
+
this.wireRealtimeListeners();
|
|
59
|
+
this.syncedOnce = true;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
53
62
|
// Map current devices
|
|
54
63
|
const wanted = new Map();
|
|
55
64
|
for (const d of devices)
|
|
56
65
|
wanted.set(uuidFor(d), d);
|
|
66
|
+
const toUnregister = this.accessories.filter((acc) => !wanted.has(acc.UUID));
|
|
67
|
+
if (toUnregister.length && this.shouldSkipStaleRemoval(devices.length, toUnregister.length)) {
|
|
68
|
+
this.log.error(`Suspicious partial discovery detected; preserving ${this.accessories.length} cached accessories. ` +
|
|
69
|
+
`Discovery returned ${devices.length} devices and would remove ${toUnregister.length}.`);
|
|
70
|
+
this.rebuildAccessoryVidMap();
|
|
71
|
+
this.wireRealtimeListeners();
|
|
72
|
+
this.syncedOnce = true;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
57
75
|
// Update existing / mark seen
|
|
58
76
|
const seen = new Set();
|
|
59
77
|
const toUpdate = [];
|
|
@@ -93,10 +111,9 @@ class VantagePlatform {
|
|
|
93
111
|
this.log.info(`Registered ${toRegister.length} new accessories`);
|
|
94
112
|
}
|
|
95
113
|
// Unregister stale
|
|
96
|
-
const toUnregister = this.accessories.filter((acc) => !wanted.has(acc.UUID));
|
|
97
114
|
if (toUnregister.length) {
|
|
98
115
|
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, toUnregister);
|
|
99
|
-
this.log.info(`Unregistered ${toUnregister.length} stale accessories`);
|
|
116
|
+
this.log.info(`Unregistered ${toUnregister.length} stale accessories after successful discovery`);
|
|
100
117
|
// Remove from local cache & VID map
|
|
101
118
|
for (const acc of toUnregister) {
|
|
102
119
|
const vid = acc?.context?.device?.vid;
|
|
@@ -111,6 +128,24 @@ class VantagePlatform {
|
|
|
111
128
|
this.wireRealtimeListeners();
|
|
112
129
|
this.syncedOnce = true;
|
|
113
130
|
}
|
|
131
|
+
shouldSkipStaleRemoval(discoveredCount, staleCount) {
|
|
132
|
+
const cachedCount = this.accessories.length;
|
|
133
|
+
if (cachedCount === 0 || staleCount === 0)
|
|
134
|
+
return false;
|
|
135
|
+
const removalRatio = staleCount / cachedCount;
|
|
136
|
+
const discoveredRatio = discoveredCount / cachedCount;
|
|
137
|
+
// Allow small cleanup, but refuse sudden large percentage drops that look like partial discovery.
|
|
138
|
+
return staleCount >= 5 && removalRatio > 0.5 && discoveredRatio < 0.5;
|
|
139
|
+
}
|
|
140
|
+
rebuildAccessoryVidMap() {
|
|
141
|
+
this.accessoriesByVid.clear();
|
|
142
|
+
for (const acc of this.accessories) {
|
|
143
|
+
const vid = acc?.context?.device?.vid;
|
|
144
|
+
if (vid !== undefined && vid !== null) {
|
|
145
|
+
this.accessoriesByVid.set(String(vid), acc);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
114
149
|
wireRealtimeListeners() {
|
|
115
150
|
if (this.realtimeWired)
|
|
116
151
|
return;
|
|
@@ -9,6 +9,7 @@ interface Options {
|
|
|
9
9
|
omit: string;
|
|
10
10
|
range: string;
|
|
11
11
|
log: Logger;
|
|
12
|
+
storagePath: string;
|
|
12
13
|
forceSSL?: boolean;
|
|
13
14
|
}
|
|
14
15
|
export declare class VantageInfusion extends EventEmitter {
|
|
@@ -17,7 +18,10 @@ export declare class VantageInfusion extends EventEmitter {
|
|
|
17
18
|
private command;
|
|
18
19
|
private isInsecureCmd;
|
|
19
20
|
private isInsecureCfg;
|
|
20
|
-
private
|
|
21
|
+
private getPersistentCachePath;
|
|
22
|
+
private getPersistentCacheDirectory;
|
|
23
|
+
private getLegacyCachePaths;
|
|
24
|
+
private cacheKeyForIp;
|
|
21
25
|
constructor(opts: Options);
|
|
22
26
|
/** Probe ports and open command session */
|
|
23
27
|
start(): Promise<void>;
|
|
@@ -29,11 +33,19 @@ export declare class VantageInfusion extends EventEmitter {
|
|
|
29
33
|
/** XML/config discovery; returns devices (replaces old Discover/DiscoverSSL) */
|
|
30
34
|
discoverDevices(): Promise<VantageDevice[]>;
|
|
31
35
|
private processConfigurationXml;
|
|
36
|
+
private parseAndValidateConfigurationXml;
|
|
37
|
+
private isRelevantConfigObject;
|
|
38
|
+
private errorMessage;
|
|
39
|
+
private loadPersistentCache;
|
|
40
|
+
private migrateLegacyCacheIfNeeded;
|
|
32
41
|
/** Escape text for XML (same as sanitize in index.js). */
|
|
33
42
|
private static escapeXml;
|
|
34
43
|
/** Build cache XML from readObjects (same structure + sanitization as original index.js). */
|
|
35
44
|
private buildCacheXml;
|
|
36
45
|
private pullConfigurationXml;
|
|
46
|
+
private writePersistentCacheAtomically;
|
|
47
|
+
private validateCandidateAgainstExistingCache;
|
|
48
|
+
private validateExistingCacheForReplacement;
|
|
37
49
|
setBrightness(vid: string, level: number): Promise<void>;
|
|
38
50
|
setHue(vid: string, hue: number): Promise<void>;
|
|
39
51
|
setSaturation(vid: string, sat: number): Promise<void>;
|
package/dist/vantageInfusion.js
CHANGED
|
@@ -40,7 +40,6 @@ const tls = __importStar(require("tls"));
|
|
|
40
40
|
const xml2js_1 = require("xml2js");
|
|
41
41
|
const fs = __importStar(require("fs"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
|
-
const os = __importStar(require("os"));
|
|
44
43
|
const TYPE_THERMO = [
|
|
45
44
|
'Thermostat',
|
|
46
45
|
'Vantage.HVAC-Interface_Point_Zone_CHILD',
|
|
@@ -71,14 +70,27 @@ const OBJECT_TYPES = [
|
|
|
71
70
|
...TYPE_THERMO,
|
|
72
71
|
...TYPE_BLIND,
|
|
73
72
|
];
|
|
73
|
+
const DISCOVERABLE_OBJECT_TYPES = OBJECT_TYPES.filter((type) => type !== 'Area');
|
|
74
|
+
class CandidateConfigurationError extends Error {
|
|
75
|
+
constructor(message) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = 'CandidateConfigurationError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
74
80
|
class VantageInfusion extends events_1.EventEmitter {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return path.join(
|
|
81
|
+
getPersistentCachePath() {
|
|
82
|
+
const fileName = `vantage-${this.cacheKeyForIp(this.opts.ipaddress)}.dc`;
|
|
83
|
+
// Controller-level cache is intentionally not range-specific; range-split main/child bridge instances share it.
|
|
84
|
+
return path.join(this.getPersistentCacheDirectory(), fileName);
|
|
85
|
+
}
|
|
86
|
+
getPersistentCacheDirectory() {
|
|
87
|
+
return path.join(this.opts.storagePath, 'vantage');
|
|
88
|
+
}
|
|
89
|
+
getLegacyCachePaths() {
|
|
90
|
+
return [path.join('/tmp', `vantage-${this.cacheKeyForIp(this.opts.ipaddress)}.dc`)];
|
|
91
|
+
}
|
|
92
|
+
cacheKeyForIp(ipaddress) {
|
|
93
|
+
return ipaddress.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
82
94
|
}
|
|
83
95
|
constructor(opts) {
|
|
84
96
|
super();
|
|
@@ -86,6 +98,7 @@ class VantageInfusion extends events_1.EventEmitter {
|
|
|
86
98
|
this.xml = new xml2js_1.Parser({ explicitArray: false, mergeAttrs: true, trim: true });
|
|
87
99
|
this.isInsecureCmd = true; // 3001 vs 3010
|
|
88
100
|
this.isInsecureCfg = true; // 2001 vs 2010
|
|
101
|
+
this.opts.log.debug(`Persistent Vantage cache path: ${this.getPersistentCachePath()}`);
|
|
89
102
|
}
|
|
90
103
|
/** Probe ports and open command session */
|
|
91
104
|
async start() {
|
|
@@ -262,30 +275,47 @@ class VantageInfusion extends events_1.EventEmitter {
|
|
|
262
275
|
}
|
|
263
276
|
/** XML/config discovery; returns devices (replaces old Discover/DiscoverSSL) */
|
|
264
277
|
async discoverDevices() {
|
|
265
|
-
// Check for cached configuration first
|
|
266
278
|
if (this.opts.usecache) {
|
|
267
|
-
|
|
268
|
-
|
|
279
|
+
await this.migrateLegacyCacheIfNeeded();
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
this.opts.log.info(`Attempting fresh Vantage configuration download from ${this.opts.ipaddress}`);
|
|
283
|
+
const xml = await this.pullConfigurationXml();
|
|
284
|
+
const validation = await this.parseAndValidateConfigurationXml(xml, 'downloaded configuration');
|
|
285
|
+
this.opts.log.info(`Fresh Vantage controller configuration validated successfully (${validation.relevantObjectCount} relevant objects)`);
|
|
286
|
+
if (this.opts.usecache) {
|
|
269
287
|
try {
|
|
270
|
-
|
|
271
|
-
this.opts.log.info(`Using cached configuration from ${cachePath}`);
|
|
272
|
-
return this.processConfigurationXml(cachedXml);
|
|
288
|
+
await this.writePersistentCacheAtomically(xml, validation);
|
|
273
289
|
}
|
|
274
290
|
catch (error) {
|
|
275
|
-
|
|
291
|
+
if (error instanceof CandidateConfigurationError) {
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
this.opts.log.warn(`Persistent cache update failed; using live configuration for this run: ${this.errorMessage(error)}`);
|
|
276
295
|
}
|
|
277
296
|
}
|
|
297
|
+
// Legacy: "end configuration download"
|
|
298
|
+
this.opts.log.debug('VantagePlatform for InFusion Controller (end configuration download)');
|
|
299
|
+
const devices = await this.processConfigurationXml(xml, 'downloaded configuration');
|
|
300
|
+
this.opts.log.info('Fresh Vantage controller configuration loaded successfully');
|
|
301
|
+
return devices;
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
this.opts.log.warn(`Fresh controller discovery failed; attempting persistent fallback cache: ${this.errorMessage(error)}`);
|
|
305
|
+
if (this.opts.usecache) {
|
|
306
|
+
try {
|
|
307
|
+
return await this.loadPersistentCache();
|
|
308
|
+
}
|
|
309
|
+
catch (cacheError) {
|
|
310
|
+
this.opts.log.error(`Persistent cache unavailable; preserving existing Homebridge accessories: ${this.errorMessage(cacheError)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
throw error;
|
|
278
314
|
}
|
|
279
|
-
const xml = await this.pullConfigurationXml();
|
|
280
|
-
// Legacy: "end configuration download"
|
|
281
|
-
this.opts.log.debug('VantagePlatform for InFusion Controller (end configuration download)');
|
|
282
|
-
return this.processConfigurationXml(xml);
|
|
283
315
|
}
|
|
284
|
-
async processConfigurationXml(xml) {
|
|
285
|
-
const
|
|
286
|
-
const parsed = await this.xml.parseStringPromise(sanitized).catch(() => ({}));
|
|
316
|
+
async processConfigurationXml(xml, source = 'configuration') {
|
|
317
|
+
const { objects } = await this.parseAndValidateConfigurationXml(xml, source);
|
|
287
318
|
const devices = [];
|
|
288
|
-
const objects = parsed?.Project?.Objects?.Object || [];
|
|
289
319
|
const Areas = objects.filter((el) => Object.keys(el)[0] === 'Area');
|
|
290
320
|
const Area = {};
|
|
291
321
|
for (const a of Areas)
|
|
@@ -420,6 +450,84 @@ class VantageInfusion extends events_1.EventEmitter {
|
|
|
420
450
|
}
|
|
421
451
|
return filtered;
|
|
422
452
|
}
|
|
453
|
+
async parseAndValidateConfigurationXml(xml, source) {
|
|
454
|
+
const sanitized = this.sanitizeXml(xml);
|
|
455
|
+
let parsedAny;
|
|
456
|
+
try {
|
|
457
|
+
parsedAny = await this.xml.parseStringPromise(sanitized);
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
const message = `${source} rejected: XML parsing failed: ${this.errorMessage(error)}`;
|
|
461
|
+
this.opts.log.error(message);
|
|
462
|
+
throw new Error(message);
|
|
463
|
+
}
|
|
464
|
+
const parsed = parsedAny?.smarterHome ?? parsedAny;
|
|
465
|
+
const objectNode = parsed?.Project?.Objects?.Object;
|
|
466
|
+
if (!objectNode) {
|
|
467
|
+
const message = `${source} rejected: missing Project.Objects.Object configuration structure`;
|
|
468
|
+
this.opts.log.error(message);
|
|
469
|
+
throw new Error(message);
|
|
470
|
+
}
|
|
471
|
+
const objects = Array.isArray(objectNode) ? objectNode : [objectNode];
|
|
472
|
+
if (objects.length === 0) {
|
|
473
|
+
const message = `${source} rejected: Project.Objects.Object contains no objects`;
|
|
474
|
+
this.opts.log.error(message);
|
|
475
|
+
throw new Error(message);
|
|
476
|
+
}
|
|
477
|
+
const relevantObjectCount = objects.filter((object) => this.isRelevantConfigObject(object)).length;
|
|
478
|
+
if (relevantObjectCount === 0) {
|
|
479
|
+
const message = `${source} rejected: configuration contains no discoverable Vantage objects`;
|
|
480
|
+
this.opts.log.error(message);
|
|
481
|
+
throw new Error(message);
|
|
482
|
+
}
|
|
483
|
+
return { objects, relevantObjectCount };
|
|
484
|
+
}
|
|
485
|
+
isRelevantConfigObject(raw) {
|
|
486
|
+
if (!raw || typeof raw !== 'object')
|
|
487
|
+
return false;
|
|
488
|
+
const key = Object.keys(raw)[0];
|
|
489
|
+
const value = raw[key];
|
|
490
|
+
if (!value || typeof value !== 'object')
|
|
491
|
+
return false;
|
|
492
|
+
const objectType = value?.ObjectType ?? key;
|
|
493
|
+
return DISCOVERABLE_OBJECT_TYPES.includes(key) || DISCOVERABLE_OBJECT_TYPES.includes(objectType);
|
|
494
|
+
}
|
|
495
|
+
errorMessage(error) {
|
|
496
|
+
return error instanceof Error ? error.message : String(error);
|
|
497
|
+
}
|
|
498
|
+
async loadPersistentCache() {
|
|
499
|
+
const cachePath = this.getPersistentCachePath();
|
|
500
|
+
if (!fs.existsSync(cachePath)) {
|
|
501
|
+
throw new Error(`persistent cache not found at ${cachePath}`);
|
|
502
|
+
}
|
|
503
|
+
const cachedXml = fs.readFileSync(cachePath, 'utf8');
|
|
504
|
+
const devices = await this.processConfigurationXml(cachedXml, `persistent last-known-good cache ${cachePath}`);
|
|
505
|
+
this.opts.log.warn(`Using persistent last-known-good Vantage configuration from ${cachePath}`);
|
|
506
|
+
return devices;
|
|
507
|
+
}
|
|
508
|
+
async migrateLegacyCacheIfNeeded() {
|
|
509
|
+
const persistentCachePath = this.getPersistentCachePath();
|
|
510
|
+
if (fs.existsSync(persistentCachePath))
|
|
511
|
+
return;
|
|
512
|
+
for (const legacyPath of this.getLegacyCachePaths()) {
|
|
513
|
+
if (!fs.existsSync(legacyPath))
|
|
514
|
+
continue;
|
|
515
|
+
try {
|
|
516
|
+
const legacyXml = fs.readFileSync(legacyPath, 'utf8');
|
|
517
|
+
const validation = await this.parseAndValidateConfigurationXml(legacyXml, `legacy cache ${legacyPath}`);
|
|
518
|
+
await this.writePersistentCacheAtomically(legacyXml, validation);
|
|
519
|
+
this.opts.log.info(`Migrated legacy Vantage cache from ${legacyPath} to ${persistentCachePath}`);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
this.opts.log.warn(`Legacy cache rejected from ${legacyPath}: ${this.errorMessage(error)}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const unkeyedLegacyPath = path.join('/tmp', 'vantage.dc');
|
|
527
|
+
if (fs.existsSync(unkeyedLegacyPath)) {
|
|
528
|
+
this.opts.log.warn(`Legacy cache ${unkeyedLegacyPath} exists but was not migrated because it is not controller-specific.`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
423
531
|
/** Escape text for XML (same as sanitize in index.js). */
|
|
424
532
|
static escapeXml(s) {
|
|
425
533
|
return String(s)
|
|
@@ -440,24 +548,12 @@ class VantageInfusion extends events_1.EventEmitter {
|
|
|
440
548
|
return `<Project><Objects>${frag}</Objects></Project>`;
|
|
441
549
|
}
|
|
442
550
|
pullConfigurationXml() {
|
|
443
|
-
return new Promise((resolve) => {
|
|
551
|
+
return new Promise((resolve, reject) => {
|
|
444
552
|
const finishOnce = (socket, xml) => {
|
|
445
553
|
if (finishOnce._done)
|
|
446
554
|
return;
|
|
447
555
|
finishOnce._done = true;
|
|
448
|
-
//
|
|
449
|
-
if (this.opts.usecache) {
|
|
450
|
-
try {
|
|
451
|
-
const cachePath = this.getCacheFilePath();
|
|
452
|
-
fs.writeFileSync(cachePath, xml);
|
|
453
|
-
this.opts.log.info(`Configuration cached to ${cachePath}`);
|
|
454
|
-
}
|
|
455
|
-
catch (error) {
|
|
456
|
-
this.opts.log.warn(`Failed to cache configuration: ${error}`);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
// legacy log + event
|
|
460
|
-
this.opts.log.debug('VantagePlatform for InFusion Controller (end configuration download)');
|
|
556
|
+
// legacy event
|
|
461
557
|
this.emit('endDownloadConfiguration', xml);
|
|
462
558
|
try {
|
|
463
559
|
socket.destroy();
|
|
@@ -465,6 +561,16 @@ class VantageInfusion extends events_1.EventEmitter {
|
|
|
465
561
|
catch { /* noop */ }
|
|
466
562
|
resolve(xml);
|
|
467
563
|
};
|
|
564
|
+
const failOnce = (socket, error) => {
|
|
565
|
+
if (finishOnce._done)
|
|
566
|
+
return;
|
|
567
|
+
finishOnce._done = true;
|
|
568
|
+
try {
|
|
569
|
+
socket.destroy();
|
|
570
|
+
}
|
|
571
|
+
catch { /* noop */ }
|
|
572
|
+
reject(error);
|
|
573
|
+
};
|
|
468
574
|
const onData = (socket) => {
|
|
469
575
|
this.opts.log.info('load dc file'); // legacy
|
|
470
576
|
let buffer = '';
|
|
@@ -599,31 +705,108 @@ class VantageInfusion extends events_1.EventEmitter {
|
|
|
599
705
|
});
|
|
600
706
|
socket.on('end', () => {
|
|
601
707
|
this.opts.log.info("ending" + shouldBreak);
|
|
708
|
+
if (finishOnce._done)
|
|
709
|
+
return;
|
|
602
710
|
if (!finishOnce._done && readObjects.length) {
|
|
603
711
|
const xml = this.buildCacheXml(readObjects);
|
|
604
712
|
clearLoginFallback();
|
|
605
713
|
clearInterval(watchdog);
|
|
606
714
|
finishOnce(socket, xml);
|
|
607
715
|
}
|
|
716
|
+
else {
|
|
717
|
+
clearLoginFallback();
|
|
718
|
+
clearInterval(watchdog);
|
|
719
|
+
failOnce(socket, new Error('Connection ended before Vantage configuration was downloaded'));
|
|
720
|
+
}
|
|
608
721
|
});
|
|
609
722
|
socket.on('close', () => {
|
|
610
723
|
this.opts.log.debug("closing " + shouldBreak);
|
|
724
|
+
if (finishOnce._done)
|
|
725
|
+
return;
|
|
611
726
|
if (!finishOnce._done && readObjects.length) {
|
|
612
727
|
const xml = this.buildCacheXml(readObjects);
|
|
613
728
|
clearLoginFallback();
|
|
614
729
|
clearInterval(watchdog);
|
|
615
730
|
finishOnce(socket, xml);
|
|
616
731
|
}
|
|
732
|
+
else {
|
|
733
|
+
clearLoginFallback();
|
|
734
|
+
clearInterval(watchdog);
|
|
735
|
+
failOnce(socket, new Error('Connection closed before Vantage configuration was downloaded'));
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
socket.on('timeout', () => {
|
|
739
|
+
clearLoginFallback();
|
|
740
|
+
clearInterval(watchdog);
|
|
741
|
+
failOnce(socket, new Error('Timed out while downloading Vantage configuration'));
|
|
742
|
+
});
|
|
743
|
+
socket.on('error', (error) => {
|
|
744
|
+
clearLoginFallback();
|
|
745
|
+
clearInterval(watchdog);
|
|
746
|
+
failOnce(socket, error);
|
|
617
747
|
});
|
|
748
|
+
socket.setTimeout(30000);
|
|
618
749
|
};
|
|
619
750
|
if (this.isInsecureCfg) {
|
|
620
751
|
const s = net.connect({ host: this.opts.ipaddress, port: 2001 }, () => onData(s));
|
|
752
|
+
s.once('error', (error) => failOnce(s, error));
|
|
753
|
+
s.setTimeout(30000, () => failOnce(s, new Error('Timed out connecting to Vantage configuration port')));
|
|
621
754
|
}
|
|
622
755
|
else {
|
|
623
756
|
const s = tls.connect(2010, this.opts.ipaddress, { rejectUnauthorized: false, requestCert: true }, () => onData(s));
|
|
757
|
+
s.once('error', (error) => failOnce(s, error));
|
|
758
|
+
s.setTimeout(30000, () => failOnce(s, new Error('Timed out connecting to Vantage configuration port')));
|
|
624
759
|
}
|
|
625
760
|
});
|
|
626
761
|
}
|
|
762
|
+
async writePersistentCacheAtomically(xml, candidateValidation) {
|
|
763
|
+
const cachePath = this.getPersistentCachePath();
|
|
764
|
+
const tmpPath = `${cachePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
765
|
+
await this.validateCandidateAgainstExistingCache(cachePath, candidateValidation);
|
|
766
|
+
try {
|
|
767
|
+
fs.mkdirSync(this.getPersistentCacheDirectory(), { recursive: true });
|
|
768
|
+
fs.writeFileSync(tmpPath, xml, 'utf8');
|
|
769
|
+
const tmpXml = fs.readFileSync(tmpPath, 'utf8');
|
|
770
|
+
const validation = await this.parseAndValidateConfigurationXml(tmpXml, `temporary cache ${tmpPath}`);
|
|
771
|
+
await this.validateCandidateAgainstExistingCache(cachePath, validation);
|
|
772
|
+
fs.renameSync(tmpPath, cachePath);
|
|
773
|
+
this.opts.log.info(`Validated controller configuration accepted and persistent cache updated atomically at ${cachePath} ` +
|
|
774
|
+
`(${validation.relevantObjectCount} relevant objects).`);
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
try {
|
|
778
|
+
if (fs.existsSync(tmpPath))
|
|
779
|
+
fs.unlinkSync(tmpPath);
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
/* preserve original error */
|
|
783
|
+
}
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
async validateCandidateAgainstExistingCache(cachePath, candidateValidation) {
|
|
788
|
+
if (!fs.existsSync(cachePath))
|
|
789
|
+
return;
|
|
790
|
+
const existingValidation = await this.validateExistingCacheForReplacement(cachePath);
|
|
791
|
+
if (existingValidation &&
|
|
792
|
+
existingValidation.relevantObjectCount >= 20 &&
|
|
793
|
+
candidateValidation.relevantObjectCount < existingValidation.relevantObjectCount * 0.5) {
|
|
794
|
+
const message = `Downloaded configuration rejected as incomplete: ${candidateValidation.relevantObjectCount} relevant objects ` +
|
|
795
|
+
`vs ${existingValidation.relevantObjectCount} in persistent cache.`;
|
|
796
|
+
this.opts.log.error(message);
|
|
797
|
+
throw new CandidateConfigurationError(message);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async validateExistingCacheForReplacement(cachePath) {
|
|
801
|
+
try {
|
|
802
|
+
const existingXml = fs.readFileSync(cachePath, 'utf8');
|
|
803
|
+
return await this.parseAndValidateConfigurationXml(existingXml, `existing cache ${cachePath}`);
|
|
804
|
+
}
|
|
805
|
+
catch (error) {
|
|
806
|
+
this.opts.log.warn(`Existing cache is invalid; replacing it with validated controller configuration: ${this.errorMessage(error)}`);
|
|
807
|
+
return undefined;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
627
810
|
// ===== Commands used by platformAccessory handlers =====
|
|
628
811
|
async setBrightness(vid, level) {
|
|
629
812
|
const lvl = Math.max(0, Math.min(100, Number(level)));
|