@smarterhomeapp/homebridge-vantage 1.0.4 → 1.0.6

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.
@@ -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
@@ -50,10 +50,27 @@ class VantagePlatform {
50
50
  // Reconcile cached accessories with current device list
51
51
  async syncAccessories(devices) {
52
52
  const uuidFor = (d) => this.api.hap.uuid.generate(String(d.vid));
53
+ if (devices.length === 0 && this.accessories.length > 0) {
54
+ this.log.error(`Discovery returned 0 devices; preserving ${this.accessories.length} cached accessories ` +
55
+ 'and rebuilding realtime VID map.');
56
+ this.rebuildAccessoryVidMap();
57
+ this.wireRealtimeListeners();
58
+ this.syncedOnce = true;
59
+ return;
60
+ }
53
61
  // Map current devices
54
62
  const wanted = new Map();
55
63
  for (const d of devices)
56
64
  wanted.set(uuidFor(d), d);
65
+ const toUnregister = this.accessories.filter((acc) => !wanted.has(acc.UUID));
66
+ if (toUnregister.length && this.shouldSkipStaleRemoval(devices.length, toUnregister.length)) {
67
+ this.log.error(`Suspicious partial discovery detected; preserving ${this.accessories.length} cached accessories. ` +
68
+ `Discovery returned ${devices.length} devices and would remove ${toUnregister.length}.`);
69
+ this.rebuildAccessoryVidMap();
70
+ this.wireRealtimeListeners();
71
+ this.syncedOnce = true;
72
+ return;
73
+ }
57
74
  // Update existing / mark seen
58
75
  const seen = new Set();
59
76
  const toUpdate = [];
@@ -93,10 +110,9 @@ class VantagePlatform {
93
110
  this.log.info(`Registered ${toRegister.length} new accessories`);
94
111
  }
95
112
  // Unregister stale
96
- const toUnregister = this.accessories.filter((acc) => !wanted.has(acc.UUID));
97
113
  if (toUnregister.length) {
98
114
  this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, toUnregister);
99
- this.log.info(`Unregistered ${toUnregister.length} stale accessories`);
115
+ this.log.info(`Unregistered ${toUnregister.length} stale accessories after successful discovery`);
100
116
  // Remove from local cache & VID map
101
117
  for (const acc of toUnregister) {
102
118
  const vid = acc?.context?.device?.vid;
@@ -111,6 +127,24 @@ class VantagePlatform {
111
127
  this.wireRealtimeListeners();
112
128
  this.syncedOnce = true;
113
129
  }
130
+ shouldSkipStaleRemoval(discoveredCount, staleCount) {
131
+ const cachedCount = this.accessories.length;
132
+ if (cachedCount === 0 || staleCount === 0)
133
+ return false;
134
+ const removalRatio = staleCount / cachedCount;
135
+ const discoveredRatio = discoveredCount / cachedCount;
136
+ // Allow small cleanup, but refuse sudden large percentage drops that look like partial discovery.
137
+ return staleCount >= 5 && removalRatio > 0.5 && discoveredRatio < 0.5;
138
+ }
139
+ rebuildAccessoryVidMap() {
140
+ this.accessoriesByVid.clear();
141
+ for (const acc of this.accessories) {
142
+ const vid = acc?.context?.device?.vid;
143
+ if (vid !== undefined && vid !== null) {
144
+ this.accessoriesByVid.set(String(vid), acc);
145
+ }
146
+ }
147
+ }
114
148
  wireRealtimeListeners() {
115
149
  if (this.realtimeWired)
116
150
  return;
@@ -21,6 +21,7 @@ class VantagePlatformAccessory {
21
21
  this.accessory.addService(this.Service.AccessoryInformation);
22
22
  info.setCharacteristic(this.Characteristic.Manufacturer, 'Vantage Controls');
23
23
  info.setCharacteristic(this.Characteristic.Model, device.objectType ?? device.type);
24
+ info.setCharacteristic(this.Characteristic.Name, device.name);
24
25
  info.setCharacteristic(this.Characteristic.SerialNumber, `VID ${device.vid}`);
25
26
  // Build the correct primary service for the type
26
27
  let primary;
@@ -184,6 +185,8 @@ class VantagePlatformAccessory {
184
185
  this.platform.log.warn(`Unsupported device type ${device.type} for ${this.accessory.displayName}`);
185
186
  return;
186
187
  }
188
+ primary.displayName = device.name;
189
+ primary.setCharacteristic(this.Characteristic.Name, device.name);
187
190
  // Mark primary (helps Home app pick the right tile)
188
191
  try {
189
192
  primary.setPrimaryService?.(true);
@@ -18,6 +18,7 @@ export declare class VantageInfusion extends EventEmitter {
18
18
  private isInsecureCmd;
19
19
  private isInsecureCfg;
20
20
  private getCacheFilePath;
21
+ private cacheKeyForIp;
21
22
  constructor(opts: Options);
22
23
  /** Probe ports and open command session */
23
24
  start(): Promise<void>;
@@ -29,11 +30,16 @@ export declare class VantageInfusion extends EventEmitter {
29
30
  /** XML/config discovery; returns devices (replaces old Discover/DiscoverSSL) */
30
31
  discoverDevices(): Promise<VantageDevice[]>;
31
32
  private processConfigurationXml;
33
+ private parseAndValidateConfigurationXml;
34
+ private isRelevantConfigObject;
35
+ private errorMessage;
32
36
  /** Escape text for XML (same as sanitize in index.js). */
33
37
  private static escapeXml;
34
38
  /** Build cache XML from readObjects (same structure + sanitization as original index.js). */
35
39
  private buildCacheXml;
36
40
  private pullConfigurationXml;
41
+ private writeCacheAtomically;
42
+ private validateExistingCacheForReplacement;
37
43
  setBrightness(vid: string, level: number): Promise<void>;
38
44
  setHue(vid: string, hue: number): Promise<void>;
39
45
  setSaturation(vid: string, sat: number): Promise<void>;
@@ -71,14 +71,19 @@ const OBJECT_TYPES = [
71
71
  ...TYPE_THERMO,
72
72
  ...TYPE_BLIND,
73
73
  ];
74
+ const DISCOVERABLE_OBJECT_TYPES = OBJECT_TYPES.filter((type) => type !== 'Area');
74
75
  class VantageInfusion extends events_1.EventEmitter {
75
76
  getCacheFilePath() {
76
- // Try /tmp first (Linux/macOS), then fall back to user's home directory
77
- const tmpPath = '/tmp/vantage.dc';
77
+ 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);
78
80
  if (fs.existsSync('/tmp')) {
79
81
  return tmpPath;
80
82
  }
81
- return path.join(os.homedir(), 'vantage.dc');
83
+ return path.join(os.homedir(), fileName);
84
+ }
85
+ cacheKeyForIp(ipaddress) {
86
+ return ipaddress.replace(/[^a-zA-Z0-9.-]/g, '_');
82
87
  }
83
88
  constructor(opts) {
84
89
  super();
@@ -268,24 +273,25 @@ class VantageInfusion extends events_1.EventEmitter {
268
273
  if (fs.existsSync(cachePath)) {
269
274
  try {
270
275
  const cachedXml = fs.readFileSync(cachePath, 'utf8');
271
- this.opts.log.info(`Using cached configuration from ${cachePath}`);
272
- return this.processConfigurationXml(cachedXml);
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;
273
279
  }
274
280
  catch (error) {
275
- this.opts.log.warn(`Failed to read cached configuration: ${error}`);
281
+ this.opts.log.warn(`Cached configuration rejected from ${cachePath}: ${this.errorMessage(error)}`);
276
282
  }
277
283
  }
278
284
  }
279
285
  const xml = await this.pullConfigurationXml();
280
286
  // Legacy: "end configuration download"
281
287
  this.opts.log.debug('VantagePlatform for InFusion Controller (end configuration download)');
282
- return this.processConfigurationXml(xml);
288
+ const devices = await this.processConfigurationXml(xml, 'downloaded configuration');
289
+ this.opts.log.info('Valid configuration loaded from controller download');
290
+ return devices;
283
291
  }
284
- async processConfigurationXml(xml) {
285
- const sanitized = this.sanitizeXml(xml);
286
- const parsed = await this.xml.parseStringPromise(sanitized).catch(() => ({}));
292
+ async processConfigurationXml(xml, source = 'configuration') {
293
+ const { objects } = await this.parseAndValidateConfigurationXml(xml, source);
287
294
  const devices = [];
288
- const objects = parsed?.Project?.Objects?.Object || [];
289
295
  const Areas = objects.filter((el) => Object.keys(el)[0] === 'Area');
290
296
  const Area = {};
291
297
  for (const a of Areas)
@@ -323,7 +329,7 @@ class VantageInfusion extends events_1.EventEmitter {
323
329
  devices.push(dev);
324
330
  };
325
331
  if (it.DeviceCategory === 'HVAC' || TYPE_THERMO.includes(it.ObjectType)) {
326
- this.opts.log.info(`New HVAC added (VID=${it.VID}, Name=${it.Name})`);
332
+ this.opts.log.info(`New HVAC added (VID=${it.VID}, Name=${name})`);
327
333
  pushUnique({
328
334
  name,
329
335
  address: vid,
@@ -357,7 +363,7 @@ class VantageInfusion extends events_1.EventEmitter {
357
363
  it.LoadType === 'Motor' ||
358
364
  it.LoadType === 'High Voltage Relay';
359
365
  if (isRelay) {
360
- this.opts.log.info(`New relay added (VID=${it.VID}, Name=${it.Name}, RELAY)`);
366
+ this.opts.log.info(`New relay added (VID=${it.VID}, Name=${name}, RELAY)`);
361
367
  pushUnique({
362
368
  name,
363
369
  address: vid,
@@ -370,7 +376,7 @@ class VantageInfusion extends events_1.EventEmitter {
370
376
  });
371
377
  }
372
378
  else if (it.ObjectType === 'Vantage.DDGColorLoad') {
373
- this.opts.log.info(`New load added (VID=${it.VID}, Name=${it.Name}, RGB)`);
379
+ this.opts.log.info(`New load added (VID=${it.VID}, Name=${name}, RGB)`);
374
380
  pushUnique({
375
381
  name,
376
382
  address: vid,
@@ -385,7 +391,7 @@ class VantageInfusion extends events_1.EventEmitter {
385
391
  });
386
392
  }
387
393
  else {
388
- this.opts.log.info(`New load added (VID=${it.VID}, Name=${it.Name}, DIMMER)`);
394
+ this.opts.log.info(`New load added (VID=${it.VID}, Name=${name}, DIMMER)`);
389
395
  pushUnique({
390
396
  name,
391
397
  address: vid,
@@ -404,7 +410,7 @@ class VantageInfusion extends events_1.EventEmitter {
404
410
  blindOpenClose[it.OpenLoad] = it.OpenLoad;
405
411
  blindOpenClose[it.CloseLoad] = it.CloseLoad;
406
412
  }
407
- this.opts.log.info(`New Blind added (VID=${it.VID}, Name=${it.Name}, BLIND)`);
413
+ this.opts.log.info(`New Blind added (VID=${it.VID}, Name=${name}, BLIND)`);
408
414
  pushUnique({ name, address: vid, type: 'blind', vid, objectType: it.ObjectType, pos: 100, posState: 2 });
409
415
  continue;
410
416
  }
@@ -420,6 +426,51 @@ class VantageInfusion extends events_1.EventEmitter {
420
426
  }
421
427
  return filtered;
422
428
  }
429
+ async parseAndValidateConfigurationXml(xml, source) {
430
+ const sanitized = this.sanitizeXml(xml);
431
+ let parsedAny;
432
+ try {
433
+ parsedAny = await this.xml.parseStringPromise(sanitized);
434
+ }
435
+ catch (error) {
436
+ const message = `${source} rejected: XML parsing failed: ${this.errorMessage(error)}`;
437
+ this.opts.log.error(message);
438
+ throw new Error(message);
439
+ }
440
+ const parsed = parsedAny?.smarterHome ?? parsedAny;
441
+ const objectNode = parsed?.Project?.Objects?.Object;
442
+ if (!objectNode) {
443
+ const message = `${source} rejected: missing Project.Objects.Object configuration structure`;
444
+ this.opts.log.error(message);
445
+ throw new Error(message);
446
+ }
447
+ const objects = Array.isArray(objectNode) ? objectNode : [objectNode];
448
+ if (objects.length === 0) {
449
+ const message = `${source} rejected: Project.Objects.Object contains no objects`;
450
+ this.opts.log.error(message);
451
+ throw new Error(message);
452
+ }
453
+ const relevantObjectCount = objects.filter((object) => this.isRelevantConfigObject(object)).length;
454
+ if (relevantObjectCount === 0) {
455
+ const message = `${source} rejected: configuration contains no discoverable Vantage objects`;
456
+ this.opts.log.error(message);
457
+ throw new Error(message);
458
+ }
459
+ return { objects, relevantObjectCount };
460
+ }
461
+ isRelevantConfigObject(raw) {
462
+ if (!raw || typeof raw !== 'object')
463
+ return false;
464
+ const key = Object.keys(raw)[0];
465
+ const value = raw[key];
466
+ if (!value || typeof value !== 'object')
467
+ return false;
468
+ const objectType = value?.ObjectType ?? key;
469
+ return DISCOVERABLE_OBJECT_TYPES.includes(key) || DISCOVERABLE_OBJECT_TYPES.includes(objectType);
470
+ }
471
+ errorMessage(error) {
472
+ return error instanceof Error ? error.message : String(error);
473
+ }
423
474
  /** Escape text for XML (same as sanitize in index.js). */
424
475
  static escapeXml(s) {
425
476
  return String(s)
@@ -440,30 +491,33 @@ class VantageInfusion extends events_1.EventEmitter {
440
491
  return `<Project><Objects>${frag}</Objects></Project>`;
441
492
  }
442
493
  pullConfigurationXml() {
443
- return new Promise((resolve) => {
444
- const finishOnce = (socket, xml) => {
494
+ return new Promise((resolve, reject) => {
495
+ const finishOnce = async (socket, xml) => {
445
496
  if (finishOnce._done)
446
497
  return;
447
498
  finishOnce._done = true;
448
499
  // Save configuration to cache if usecache is enabled
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}`);
500
+ try {
501
+ if (this.opts.usecache) {
502
+ await this.writeCacheAtomically(xml);
454
503
  }
455
- catch (error) {
456
- this.opts.log.warn(`Failed to cache configuration: ${error}`);
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();
457
509
  }
510
+ catch { /* noop */ }
511
+ resolve(xml);
458
512
  }
459
- // legacy log + event
460
- this.opts.log.debug('VantagePlatform for InFusion Controller (end configuration download)');
461
- this.emit('endDownloadConfiguration', xml);
462
- try {
463
- socket.destroy();
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);
464
520
  }
465
- catch { /* noop */ }
466
- resolve(xml);
467
521
  };
468
522
  const onData = (socket) => {
469
523
  this.opts.log.info('load dc file'); // legacy
@@ -484,7 +538,7 @@ class VantageInfusion extends events_1.EventEmitter {
484
538
  if (readObjects.length && Date.now() - lastProgress > 2000) {
485
539
  const xml = this.buildCacheXml(readObjects);
486
540
  clearInterval(watchdog);
487
- finishOnce(socket, xml);
541
+ void finishOnce(socket, xml);
488
542
  }
489
543
  }, 750);
490
544
  const writeOpenFilter = () => {
@@ -593,7 +647,7 @@ class VantageInfusion extends events_1.EventEmitter {
593
647
  const xml = this.buildCacheXml(readObjects);
594
648
  clearLoginFallback();
595
649
  clearInterval(watchdog);
596
- finishOnce(socket, xml);
650
+ void finishOnce(socket, xml);
597
651
  }
598
652
  });
599
653
  });
@@ -603,7 +657,7 @@ class VantageInfusion extends events_1.EventEmitter {
603
657
  const xml = this.buildCacheXml(readObjects);
604
658
  clearLoginFallback();
605
659
  clearInterval(watchdog);
606
- finishOnce(socket, xml);
660
+ void finishOnce(socket, xml);
607
661
  }
608
662
  });
609
663
  socket.on('close', () => {
@@ -612,7 +666,7 @@ class VantageInfusion extends events_1.EventEmitter {
612
666
  const xml = this.buildCacheXml(readObjects);
613
667
  clearLoginFallback();
614
668
  clearInterval(watchdog);
615
- finishOnce(socket, xml);
669
+ void finishOnce(socket, xml);
616
670
  }
617
671
  });
618
672
  };
@@ -624,6 +678,50 @@ class VantageInfusion extends events_1.EventEmitter {
624
678
  }
625
679
  });
626
680
  }
681
+ async writeCacheAtomically(xml) {
682
+ const cachePath = this.getCacheFilePath();
683
+ const tmpPath = `${cachePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
684
+ const candidateValidation = await this.parseAndValidateConfigurationXml(xml, 'downloaded configuration');
685
+ try {
686
+ fs.writeFileSync(tmpPath, xml, 'utf8');
687
+ const tmpXml = fs.readFileSync(tmpPath, 'utf8');
688
+ 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
+ }
700
+ fs.renameSync(tmpPath, cachePath);
701
+ this.opts.log.info(`Validated controller configuration accepted and cache replaced atomically at ${cachePath} ` +
702
+ `(${validation.relevantObjectCount} relevant objects).`);
703
+ }
704
+ catch (error) {
705
+ try {
706
+ if (fs.existsSync(tmpPath))
707
+ fs.unlinkSync(tmpPath);
708
+ }
709
+ catch {
710
+ /* preserve original error */
711
+ }
712
+ throw error;
713
+ }
714
+ }
715
+ async validateExistingCacheForReplacement(cachePath) {
716
+ try {
717
+ const existingXml = fs.readFileSync(cachePath, 'utf8');
718
+ return await this.parseAndValidateConfigurationXml(existingXml, `existing cache ${cachePath}`);
719
+ }
720
+ catch (error) {
721
+ this.opts.log.warn(`Existing cache is invalid; replacing it with validated controller configuration: ${this.errorMessage(error)}`);
722
+ return undefined;
723
+ }
724
+ }
627
725
  // ===== Commands used by platformAccessory handlers =====
628
726
  async setBrightness(vid, level) {
629
727
  const lvl = Math.max(0, Math.min(100, Number(level)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smarterhomeapp/homebridge-vantage",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Homebridge plugin for Vantage InFusion (dynamic platform, HB 1.6+ / 2.0 ready)",
5
5
  "license": "MIT",
6
6
  "keywords": [