@smarterhomeapp/homebridge-vantage 1.0.6 → 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 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 | Use cached configuration (default: true) |
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 run dev
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
- - Disable cache if configuration changes frequently
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.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 -----
@@ -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,9 @@ export declare class VantageInfusion extends EventEmitter {
17
18
  private command;
18
19
  private isInsecureCmd;
19
20
  private isInsecureCfg;
20
- private getCacheFilePath;
21
+ private getPersistentCachePath;
22
+ private getPersistentCacheDirectory;
23
+ private getLegacyCachePaths;
21
24
  private cacheKeyForIp;
22
25
  constructor(opts: Options);
23
26
  /** Probe ports and open command session */
@@ -33,12 +36,15 @@ export declare class VantageInfusion extends EventEmitter {
33
36
  private parseAndValidateConfigurationXml;
34
37
  private isRelevantConfigObject;
35
38
  private errorMessage;
39
+ private loadPersistentCache;
40
+ private migrateLegacyCacheIfNeeded;
36
41
  /** Escape text for XML (same as sanitize in index.js). */
37
42
  private static escapeXml;
38
43
  /** Build cache XML from readObjects (same structure + sanitization as original index.js). */
39
44
  private buildCacheXml;
40
45
  private pullConfigurationXml;
41
- private writeCacheAtomically;
46
+ private writePersistentCacheAtomically;
47
+ private validateCandidateAgainstExistingCache;
42
48
  private validateExistingCacheForReplacement;
43
49
  setBrightness(vid: string, level: number): Promise<void>;
44
50
  setHue(vid: string, hue: number): Promise<void>;
@@ -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',
@@ -72,15 +71,23 @@ const OBJECT_TYPES = [
72
71
  ...TYPE_BLIND,
73
72
  ];
74
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
+ }
75
80
  class VantageInfusion extends events_1.EventEmitter {
76
- getCacheFilePath() {
81
+ getPersistentCachePath() {
77
82
  const fileName = `vantage-${this.cacheKeyForIp(this.opts.ipaddress)}.dc`;
78
- // Try /tmp first (Linux/macOS), then fall back to user's home directory.
79
- const tmpPath = path.join('/tmp', fileName);
80
- if (fs.existsSync('/tmp')) {
81
- return tmpPath;
82
- }
83
- return path.join(os.homedir(), fileName);
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`)];
84
91
  }
85
92
  cacheKeyForIp(ipaddress) {
86
93
  return ipaddress.replace(/[^a-zA-Z0-9.-]/g, '_');
@@ -91,6 +98,7 @@ class VantageInfusion extends events_1.EventEmitter {
91
98
  this.xml = new xml2js_1.Parser({ explicitArray: false, mergeAttrs: true, trim: true });
92
99
  this.isInsecureCmd = true; // 3001 vs 3010
93
100
  this.isInsecureCfg = true; // 2001 vs 2010
101
+ this.opts.log.debug(`Persistent Vantage cache path: ${this.getPersistentCachePath()}`);
94
102
  }
95
103
  /** Probe ports and open command session */
96
104
  async start() {
@@ -267,27 +275,43 @@ class VantageInfusion extends events_1.EventEmitter {
267
275
  }
268
276
  /** XML/config discovery; returns devices (replaces old Discover/DiscoverSSL) */
269
277
  async discoverDevices() {
270
- // Check for cached configuration first
271
278
  if (this.opts.usecache) {
272
- const cachePath = this.getCacheFilePath();
273
- if (fs.existsSync(cachePath)) {
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) {
274
287
  try {
275
- const cachedXml = fs.readFileSync(cachePath, 'utf8');
276
- const devices = await this.processConfigurationXml(cachedXml, `cached configuration from ${cachePath}`);
277
- this.opts.log.info(`Valid cached configuration loaded from ${cachePath}`);
278
- return devices;
288
+ await this.writePersistentCacheAtomically(xml, validation);
279
289
  }
280
290
  catch (error) {
281
- this.opts.log.warn(`Cached configuration rejected from ${cachePath}: ${this.errorMessage(error)}`);
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)}`);
282
295
  }
283
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;
284
314
  }
285
- const xml = await this.pullConfigurationXml();
286
- // Legacy: "end configuration download"
287
- this.opts.log.debug('VantagePlatform for InFusion Controller (end configuration download)');
288
- const devices = await this.processConfigurationXml(xml, 'downloaded configuration');
289
- this.opts.log.info('Valid configuration loaded from controller download');
290
- return devices;
291
315
  }
292
316
  async processConfigurationXml(xml, source = 'configuration') {
293
317
  const { objects } = await this.parseAndValidateConfigurationXml(xml, source);
@@ -471,6 +495,39 @@ class VantageInfusion extends events_1.EventEmitter {
471
495
  errorMessage(error) {
472
496
  return error instanceof Error ? error.message : String(error);
473
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
+ }
474
531
  /** Escape text for XML (same as sanitize in index.js). */
475
532
  static escapeXml(s) {
476
533
  return String(s)
@@ -492,32 +549,27 @@ class VantageInfusion extends events_1.EventEmitter {
492
549
  }
493
550
  pullConfigurationXml() {
494
551
  return new Promise((resolve, reject) => {
495
- const finishOnce = async (socket, xml) => {
552
+ const finishOnce = (socket, xml) => {
496
553
  if (finishOnce._done)
497
554
  return;
498
555
  finishOnce._done = true;
499
- // Save configuration to cache if usecache is enabled
556
+ // legacy event
557
+ this.emit('endDownloadConfiguration', xml);
500
558
  try {
501
- if (this.opts.usecache) {
502
- await this.writeCacheAtomically(xml);
503
- }
504
- // legacy log + event
505
- this.opts.log.debug('VantagePlatform for InFusion Controller (end configuration download)');
506
- this.emit('endDownloadConfiguration', xml);
507
- try {
508
- socket.destroy();
509
- }
510
- catch { /* noop */ }
511
- resolve(xml);
559
+ socket.destroy();
512
560
  }
513
- catch (error) {
514
- this.opts.log.error(`Downloaded configuration rejected; preserving existing cache: ${this.errorMessage(error)}`);
515
- try {
516
- socket.destroy();
517
- }
518
- catch { /* noop */ }
519
- reject(error);
561
+ catch { /* noop */ }
562
+ resolve(xml);
563
+ };
564
+ const failOnce = (socket, error) => {
565
+ if (finishOnce._done)
566
+ return;
567
+ finishOnce._done = true;
568
+ try {
569
+ socket.destroy();
520
570
  }
571
+ catch { /* noop */ }
572
+ reject(error);
521
573
  };
522
574
  const onData = (socket) => {
523
575
  this.opts.log.info('load dc file'); // legacy
@@ -538,7 +590,7 @@ class VantageInfusion extends events_1.EventEmitter {
538
590
  if (readObjects.length && Date.now() - lastProgress > 2000) {
539
591
  const xml = this.buildCacheXml(readObjects);
540
592
  clearInterval(watchdog);
541
- void finishOnce(socket, xml);
593
+ finishOnce(socket, xml);
542
594
  }
543
595
  }, 750);
544
596
  const writeOpenFilter = () => {
@@ -647,58 +699,78 @@ class VantageInfusion extends events_1.EventEmitter {
647
699
  const xml = this.buildCacheXml(readObjects);
648
700
  clearLoginFallback();
649
701
  clearInterval(watchdog);
650
- void finishOnce(socket, xml);
702
+ finishOnce(socket, xml);
651
703
  }
652
704
  });
653
705
  });
654
706
  socket.on('end', () => {
655
707
  this.opts.log.info("ending" + shouldBreak);
708
+ if (finishOnce._done)
709
+ return;
656
710
  if (!finishOnce._done && readObjects.length) {
657
711
  const xml = this.buildCacheXml(readObjects);
658
712
  clearLoginFallback();
659
713
  clearInterval(watchdog);
660
- void finishOnce(socket, xml);
714
+ finishOnce(socket, xml);
715
+ }
716
+ else {
717
+ clearLoginFallback();
718
+ clearInterval(watchdog);
719
+ failOnce(socket, new Error('Connection ended before Vantage configuration was downloaded'));
661
720
  }
662
721
  });
663
722
  socket.on('close', () => {
664
723
  this.opts.log.debug("closing " + shouldBreak);
724
+ if (finishOnce._done)
725
+ return;
665
726
  if (!finishOnce._done && readObjects.length) {
666
727
  const xml = this.buildCacheXml(readObjects);
667
728
  clearLoginFallback();
668
729
  clearInterval(watchdog);
669
- void finishOnce(socket, xml);
730
+ finishOnce(socket, xml);
670
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'));
671
742
  });
743
+ socket.on('error', (error) => {
744
+ clearLoginFallback();
745
+ clearInterval(watchdog);
746
+ failOnce(socket, error);
747
+ });
748
+ socket.setTimeout(30000);
672
749
  };
673
750
  if (this.isInsecureCfg) {
674
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')));
675
754
  }
676
755
  else {
677
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')));
678
759
  }
679
760
  });
680
761
  }
681
- async writeCacheAtomically(xml) {
682
- const cachePath = this.getCacheFilePath();
762
+ async writePersistentCacheAtomically(xml, candidateValidation) {
763
+ const cachePath = this.getPersistentCachePath();
683
764
  const tmpPath = `${cachePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
684
- const candidateValidation = await this.parseAndValidateConfigurationXml(xml, 'downloaded configuration');
765
+ await this.validateCandidateAgainstExistingCache(cachePath, candidateValidation);
685
766
  try {
767
+ fs.mkdirSync(this.getPersistentCacheDirectory(), { recursive: true });
686
768
  fs.writeFileSync(tmpPath, xml, 'utf8');
687
769
  const tmpXml = fs.readFileSync(tmpPath, 'utf8');
688
770
  const validation = await this.parseAndValidateConfigurationXml(tmpXml, `temporary cache ${tmpPath}`);
689
- if (fs.existsSync(cachePath)) {
690
- const existingValidation = await this.validateExistingCacheForReplacement(cachePath);
691
- if (existingValidation &&
692
- existingValidation.relevantObjectCount >= 20 &&
693
- candidateValidation.relevantObjectCount < existingValidation.relevantObjectCount * 0.5) {
694
- const message = `Downloaded configuration rejected as incomplete: ${candidateValidation.relevantObjectCount} relevant objects ` +
695
- `vs ${existingValidation.relevantObjectCount} in existing cache.`;
696
- this.opts.log.error(message);
697
- throw new Error(message);
698
- }
699
- }
771
+ await this.validateCandidateAgainstExistingCache(cachePath, validation);
700
772
  fs.renameSync(tmpPath, cachePath);
701
- this.opts.log.info(`Validated controller configuration accepted and cache replaced atomically at ${cachePath} ` +
773
+ this.opts.log.info(`Validated controller configuration accepted and persistent cache updated atomically at ${cachePath} ` +
702
774
  `(${validation.relevantObjectCount} relevant objects).`);
703
775
  }
704
776
  catch (error) {
@@ -712,6 +784,19 @@ class VantageInfusion extends events_1.EventEmitter {
712
784
  throw error;
713
785
  }
714
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
+ }
715
800
  async validateExistingCacheForReplacement(cachePath) {
716
801
  try {
717
802
  const existingXml = fs.readFileSync(cachePath, 'utf8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smarterhomeapp/homebridge-vantage",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Homebridge plugin for Vantage InFusion (dynamic platform, HB 1.6+ / 2.0 ready)",
5
5
  "license": "MIT",
6
6
  "keywords": [