homebridge 2.0.0-alpha.42 → 2.0.0-alpha.44

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.
Files changed (81) hide show
  1. package/dist/api.d.ts +64 -0
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +54 -0
  4. package/dist/api.js.map +1 -1
  5. package/dist/bridgeService.d.ts +1 -4
  6. package/dist/bridgeService.d.ts.map +1 -1
  7. package/dist/bridgeService.js +5 -10
  8. package/dist/bridgeService.js.map +1 -1
  9. package/dist/bridgeTypes.d.ts +54 -0
  10. package/dist/bridgeTypes.d.ts.map +1 -0
  11. package/dist/bridgeTypes.js +8 -0
  12. package/dist/bridgeTypes.js.map +1 -0
  13. package/dist/childBridgeFork.d.ts +2 -6
  14. package/dist/childBridgeFork.d.ts.map +1 -1
  15. package/dist/childBridgeFork.js +34 -93
  16. package/dist/childBridgeFork.js.map +1 -1
  17. package/dist/childBridgeService.d.ts +9 -7
  18. package/dist/childBridgeService.d.ts.map +1 -1
  19. package/dist/childBridgeService.js +9 -2
  20. package/dist/childBridgeService.js.map +1 -1
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +1 -3
  23. package/dist/cli.js.map +1 -1
  24. package/dist/ipcService.d.ts +2 -12
  25. package/dist/ipcService.d.ts.map +1 -1
  26. package/dist/ipcService.js +0 -7
  27. package/dist/ipcService.js.map +1 -1
  28. package/dist/matter/index.d.ts +1 -1
  29. package/dist/matter/index.d.ts.map +1 -1
  30. package/dist/matter/index.js.map +1 -1
  31. package/dist/matter/matterAccessoryCache.d.ts +73 -0
  32. package/dist/matter/matterAccessoryCache.d.ts.map +1 -0
  33. package/dist/matter/matterAccessoryCache.js +168 -0
  34. package/dist/matter/matterAccessoryCache.js.map +1 -0
  35. package/dist/matter/matterBehaviors.d.ts +123 -0
  36. package/dist/matter/matterBehaviors.d.ts.map +1 -0
  37. package/dist/matter/matterBehaviors.js +582 -0
  38. package/dist/matter/matterBehaviors.js.map +1 -0
  39. package/dist/matter/matterConfigValidator.d.ts +0 -1
  40. package/dist/matter/matterConfigValidator.d.ts.map +1 -1
  41. package/dist/matter/matterConfigValidator.js +15 -45
  42. package/dist/matter/matterConfigValidator.js.map +1 -1
  43. package/dist/matter/matterErrorHandler.d.ts +1 -1
  44. package/dist/matter/matterErrorHandler.d.ts.map +1 -1
  45. package/dist/matter/matterErrorHandler.js +35 -22
  46. package/dist/matter/matterErrorHandler.js.map +1 -1
  47. package/dist/matter/matterNetworkMonitor.d.ts +3 -0
  48. package/dist/matter/matterNetworkMonitor.d.ts.map +1 -1
  49. package/dist/matter/matterNetworkMonitor.js +49 -26
  50. package/dist/matter/matterNetworkMonitor.js.map +1 -1
  51. package/dist/matter/matterServer.d.ts +80 -5
  52. package/dist/matter/matterServer.d.ts.map +1 -1
  53. package/dist/matter/matterServer.js +512 -60
  54. package/dist/matter/matterServer.js.map +1 -1
  55. package/dist/matter/matterSharedTypes.d.ts +27 -21
  56. package/dist/matter/matterSharedTypes.d.ts.map +1 -1
  57. package/dist/matter/matterSharedTypes.js +3 -0
  58. package/dist/matter/matterSharedTypes.js.map +1 -1
  59. package/dist/matter/matterStorage.d.ts +17 -3
  60. package/dist/matter/matterStorage.d.ts.map +1 -1
  61. package/dist/matter/matterStorage.js +135 -38
  62. package/dist/matter/matterStorage.js.map +1 -1
  63. package/dist/matter/matterTypes.d.ts +68 -19
  64. package/dist/matter/matterTypes.d.ts.map +1 -1
  65. package/dist/matter/matterTypes.js.map +1 -1
  66. package/dist/matter/matterValidation.d.ts +57 -0
  67. package/dist/matter/matterValidation.d.ts.map +1 -0
  68. package/dist/matter/matterValidation.js +100 -0
  69. package/dist/matter/matterValidation.js.map +1 -0
  70. package/dist/plugin.d.ts.map +1 -1
  71. package/dist/plugin.js +2 -4
  72. package/dist/plugin.js.map +1 -1
  73. package/dist/server.d.ts +0 -1
  74. package/dist/server.d.ts.map +1 -1
  75. package/dist/server.js +69 -52
  76. package/dist/server.js.map +1 -1
  77. package/package.json +5 -5
  78. package/dist/matter/matterDiagnostics.d.ts +0 -121
  79. package/dist/matter/matterDiagnostics.d.ts.map +0 -1
  80. package/dist/matter/matterDiagnostics.js +0 -323
  81. package/dist/matter/matterDiagnostics.js.map +0 -1
@@ -5,23 +5,26 @@
5
5
  * Matter accessories via the Homebridge API.
6
6
  */
7
7
  import * as crypto from 'node:crypto';
8
+ import { EventEmitter } from 'node:events';
8
9
  import * as fs from 'node:fs';
9
- import { access } from 'node:fs/promises';
10
+ import { access, writeFile } from 'node:fs/promises';
10
11
  import * as os from 'node:os';
11
12
  import * as path from 'node:path';
12
13
  import process from 'node:process';
13
- import { Endpoint, Environment, ServerNode, StorageService, VendorId, } from '@matter/main';
14
+ import { Endpoint, Environment, ServerNode, StorageService, VendorId } from '@matter/main';
14
15
  import { AggregatorEndpoint } from '@matter/main/endpoints';
15
- import { ManualPairingCodeCodec, QrPairingCodeCodec, } from '@matter/types/schema';
16
+ import { ManualPairingCodeCodec, QrPairingCodeCodec } from '@matter/types/schema';
16
17
  import * as fse from 'fs-extra';
17
18
  import QRCode from 'qrcode-terminal';
18
19
  import { Logger } from '../logger.js';
19
20
  import getVersion from '../version.js';
20
- import { diagnostics } from './matterDiagnostics.js';
21
+ import { MatterAccessoryCache } from './matterAccessoryCache.js';
22
+ import { HomebridgeColorControlServer, HomebridgeDoorLockServer, HomebridgeIdentifyServer, HomebridgeLevelControlServer, HomebridgeOnOffServer, HomebridgeThermostatServer, HomebridgeWindowCoveringServer, registerHandler, setAccessoriesMap, } from './matterBehaviors.js';
21
23
  import { errorHandler } from './matterErrorHandler.js';
22
24
  import { networkMonitor } from './matterNetworkMonitor.js';
23
25
  import { MatterStorageManager } from './matterStorage.js';
24
26
  import { clusters, deviceTypes, MatterDeviceError, } from './matterTypes.js';
27
+ import { sanitizeUniqueId, truncateString, validatePort } from './matterValidation.js';
25
28
  const log = Logger.withPrefix('Matter');
26
29
  // Constants for Matter server configuration
27
30
  const DEFAULT_MATTER_PORT = 5540;
@@ -32,11 +35,14 @@ const SERVER_READY_TIMEOUT_MS = 5000;
32
35
  const SERVER_READY_POLL_INTERVAL_MS = 100;
33
36
  const SERVER_INIT_DELAY_MS = 200;
34
37
  const MAX_PASSCODE_ATTEMPTS = 100;
38
+ const FABRIC_MONITOR_INTERVAL_MS = 2000; // Check for fabric changes every 2 seconds
35
39
  /**
36
40
  * Matter Server for Homebridge Plugin API
37
41
  * Allows plugins to register Matter accessories explicitly
42
+ *
43
+ * Extends EventEmitter to provide commissioning lifecycle events
38
44
  */
39
- export class MatterServer {
45
+ export class MatterServer extends EventEmitter {
40
46
  config;
41
47
  serverNode = null;
42
48
  aggregator = null;
@@ -53,19 +59,88 @@ export class MatterServer {
53
59
  serialNumber;
54
60
  cleanupHandlers = [];
55
61
  storageManager = null;
56
- constructor(config = {}) {
57
- this.config = config;
58
- // Store the user config with defaults
59
- this.config = {
60
- port: config.port || DEFAULT_MATTER_PORT,
61
- name: config.name || 'Homebridge Matter Bridge',
62
- // Use a consistent uniqueId based on the name to ensure storage persistence
63
- uniqueId: config.uniqueId || `homebridge-matter-${config.name?.replace(/[^a-z0-9]/gi, '-') || 'bridge'}`,
64
- storagePath: config.storagePath,
65
- };
62
+ matterStoragePath;
63
+ accessoryCache = null;
64
+ // Fabric monitoring
65
+ fabricMonitorInterval = null;
66
+ previousFabrics = new Map();
67
+ constructor(config) {
68
+ super();
69
+ // Store the validated config
70
+ this.config = this.validateAndSanitizeConfig(config);
71
+ // Enable debug logging if requested
72
+ if (this.config.debugModeEnabled) {
73
+ log.info('Matter debug mode enabled - verbose logging active');
74
+ }
66
75
  // Initialize commissioning values (will be loaded from storage in start())
67
76
  this.vendorId = DEFAULT_VENDOR_ID;
68
77
  this.productId = DEFAULT_PRODUCT_ID;
78
+ // Provide accessories map reference to behaviors for cache syncing
79
+ setAccessoriesMap(this.accessories);
80
+ }
81
+ /**
82
+ * Validate and sanitize Matter server configuration
83
+ * Throws descriptive errors if configuration is invalid
84
+ */
85
+ validateAndSanitizeConfig(config) {
86
+ const errors = [];
87
+ // Validate port
88
+ const port = config.port || DEFAULT_MATTER_PORT;
89
+ const portValidation = validatePort(port, false);
90
+ if (!portValidation.valid) {
91
+ errors.push(`Invalid port: ${portValidation.error}`);
92
+ }
93
+ // Validate and sanitize uniqueId (REQUIRED)
94
+ if (!config.uniqueId) {
95
+ errors.push('uniqueId is required for Matter server configuration');
96
+ }
97
+ const rawUniqueId = config.uniqueId || '';
98
+ const uniqueIdResult = sanitizeUniqueId(rawUniqueId);
99
+ const uniqueId = uniqueIdResult.value;
100
+ if (uniqueId.length === 0) {
101
+ errors.push('Invalid uniqueId: must be a non-empty string');
102
+ }
103
+ // Validate storagePath (if provided)
104
+ let storagePath = config.storagePath;
105
+ if (storagePath !== undefined) {
106
+ storagePath = path.resolve(storagePath); // Resolve to absolute path
107
+ }
108
+ // Validate and sanitize manufacturer
109
+ let manufacturer = config.manufacturer;
110
+ if (manufacturer !== undefined) {
111
+ manufacturer = truncateString(manufacturer, 32, 'Manufacturer name').value;
112
+ }
113
+ // Validate and sanitize model
114
+ let model = config.model;
115
+ if (model !== undefined) {
116
+ model = truncateString(model, 32, 'Model name').value;
117
+ }
118
+ // Validate firmwareRevision
119
+ let firmwareRevision = config.firmwareRevision;
120
+ if (firmwareRevision !== undefined) {
121
+ firmwareRevision = truncateString(firmwareRevision, 64, 'Firmware revision').value;
122
+ }
123
+ // Validate serialNumber
124
+ let serialNumber = config.serialNumber;
125
+ if (serialNumber !== undefined) {
126
+ serialNumber = truncateString(serialNumber, 32, 'Serial number').value;
127
+ }
128
+ // Validate debugModeEnabled
129
+ const debugModeEnabled = config.debugModeEnabled || false;
130
+ // Throw if there are validation errors
131
+ if (errors.length > 0) {
132
+ throw new MatterDeviceError(`Matter configuration validation failed:\n${errors.map(e => ` - ${e}`).join('\n')}`);
133
+ }
134
+ return {
135
+ port,
136
+ uniqueId,
137
+ storagePath,
138
+ manufacturer,
139
+ model,
140
+ firmwareRevision,
141
+ serialNumber,
142
+ debugModeEnabled,
143
+ };
69
144
  }
70
145
  /**
71
146
  * Generate a secure random passcode
@@ -166,8 +241,21 @@ export class MatterServer {
166
241
  }
167
242
  /**
168
243
  * Create ServerNode with automatic recovery from corrupted storage
169
- * If ServerNode creation fails due to corrupted fabric data, automatically
170
- * clean up the ServerNodeStore and retry once
244
+ *
245
+ * Matter.js can fail to start if fabric data is corrupted (common after
246
+ * hard shutdowns or disk errors). This method implements automatic recovery by:
247
+ *
248
+ * 1. Attempting normal ServerNode creation
249
+ * 2. If it fails with storage errors, identifying and removing corrupted files
250
+ * 3. Retrying ServerNode creation with fresh storage
251
+ *
252
+ * This prevents the need for manual intervention while preserving data
253
+ * safety by only removing storage on confirmed corruption errors.
254
+ *
255
+ * @param nodeOptions - Matter.js ServerNode configuration
256
+ * @param sanitizedId - Filesystem-safe bridge identifier
257
+ * @returns Initialized ServerNode instance
258
+ * @throws Error if recovery fails or error is not storage-related
171
259
  */
172
260
  async createServerNodeWithRecovery(nodeOptions, sanitizedId) {
173
261
  try {
@@ -247,17 +335,14 @@ export class MatterServer {
247
335
  // Start network monitoring
248
336
  networkMonitor.startMonitoring();
249
337
  this.cleanupHandlers.push(() => networkMonitor.stopMonitoring());
250
- // Start diagnostics
251
- diagnostics.startDiagnostics();
252
- this.cleanupHandlers.push(() => diagnostics.stopDiagnostics());
253
338
  // Create commissioning options
254
339
  const commissioningOptions = {
255
340
  passcode: this.passcode,
256
341
  discriminator: this.discriminator,
257
342
  };
258
343
  log.info(`Using commissioning credentials: passcode=${this.passcode}, discriminator=${this.discriminator}`);
259
- // Ensure we have a name for the bridge
260
- const bridgeName = this.config.name || 'Homebridge Matter Bridge';
344
+ // Use default name for the Matter bridge
345
+ const bridgeName = 'Homebridge Matter Bridge';
261
346
  // Sanitize the uniqueId to ensure it's filesystem-safe
262
347
  // Replace any characters that could cause issues (colons, slashes, etc.)
263
348
  // Use only alphanumeric and hyphens, collapse multiple hyphens, trim leading/trailing hyphens
@@ -277,15 +362,15 @@ export class MatterServer {
277
362
  basicInformation: {
278
363
  nodeLabel: bridgeName.slice(0, 32), // Maximum 32 characters
279
364
  vendorId: VendorId(this.vendorId),
280
- vendorName: 'Homebridge'.slice(0, 32),
365
+ vendorName: (this.config.manufacturer || 'Homebridge').slice(0, 32),
281
366
  productId: this.productId,
282
- productName: 'Homebridge Matter Bridge'.slice(0, 32),
367
+ productName: (this.config.model || 'Homebridge Matter Bridge').slice(0, 32),
283
368
  productLabel: bridgeName.slice(0, 64), // Maximum 64 characters
284
- serialNumber: this.serialNumber = this.generateSerialNumber(),
369
+ serialNumber: this.serialNumber = this.config.serialNumber || this.generateSerialNumber(),
285
370
  hardwareVersion: 1,
286
371
  hardwareVersionString: os.release(),
287
372
  softwareVersion: 1,
288
- softwareVersionString: getVersion(),
373
+ softwareVersionString: this.config.firmwareRevision || getVersion(),
289
374
  reachable: true,
290
375
  },
291
376
  };
@@ -316,6 +401,12 @@ export class MatterServer {
316
401
  });
317
402
  // Wait for server to be ready
318
403
  await this.waitForServerReady();
404
+ // Load cached accessories (don't restore them yet - wait for plugins to re-register)
405
+ if (this.accessoryCache) {
406
+ await this.accessoryCache.load();
407
+ }
408
+ // Start fabric monitoring to emit commissioning events
409
+ this.startFabricMonitoring();
319
410
  this.isRunning = true;
320
411
  log.info(`Matter server started successfully on port ${this.config.port}`);
321
412
  log.info('Plugins can now register Matter accessories via the API');
@@ -357,14 +448,16 @@ export class MatterServer {
357
448
  // Create bridge-specific storage directory
358
449
  // Use only alphanumeric characters and hyphens for maximum compatibility
359
450
  const bridgeId = this.config.uniqueId?.replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'default';
360
- const matterStoragePath = path.join(normalizedPath, bridgeId);
361
- await fse.ensureDir(matterStoragePath);
451
+ this.matterStoragePath = path.join(normalizedPath, bridgeId);
452
+ await fse.ensureDir(this.matterStoragePath);
362
453
  // Create storage manager
363
- this.storageManager = new MatterStorageManager(matterStoragePath);
454
+ this.storageManager = new MatterStorageManager(this.matterStoragePath);
455
+ // Create accessory cache
456
+ this.accessoryCache = new MatterAccessoryCache(normalizedPath, bridgeId);
364
457
  // Configure environment to use our custom storage
365
458
  const environment = Environment.default;
366
459
  const storageService = environment.get(StorageService);
367
- storageService.location = matterStoragePath;
460
+ storageService.location = this.matterStoragePath;
368
461
  // CRITICAL: Override storage factory with custom implementation
369
462
  // This ensures fabric data is properly persisted
370
463
  storageService.factory = (namespace) => {
@@ -385,7 +478,7 @@ export class MatterServer {
385
478
  await this.storageManager.closeAll();
386
479
  }
387
480
  });
388
- log.info(`Matter storage initialized at: ${matterStoragePath}`);
481
+ log.info(`Matter storage initialized at: ${this.matterStoragePath}`);
389
482
  }
390
483
  /**
391
484
  * Load or generate commissioning credentials (passcode and discriminator)
@@ -395,7 +488,8 @@ export class MatterServer {
395
488
  if (!this.storageManager) {
396
489
  throw new Error('Storage manager not initialized');
397
490
  }
398
- const storage = this.storageManager.getStorage('commissioning');
491
+ // Use 'credentials' namespace
492
+ const storage = this.storageManager.getStorage('credentials');
399
493
  // CRITICAL: Initialize storage before reading to avoid race condition
400
494
  await storage.initialize();
401
495
  // Try to load existing credentials
@@ -458,6 +552,26 @@ export class MatterServer {
458
552
  qrCode: qrCodePayload,
459
553
  manualPairingCode,
460
554
  };
555
+ // Save commissioning info to disk for UI access
556
+ try {
557
+ if (!this.matterStoragePath) {
558
+ throw new Error('Matter storage path not initialized');
559
+ }
560
+ const commissioningFilePath = path.join(this.matterStoragePath, 'commissioning.json');
561
+ const commissioningData = {
562
+ qrCode: qrCodePayload,
563
+ manualPairingCode,
564
+ serialNumber: this.serialNumber,
565
+ passcode: this.passcode,
566
+ discriminator: this.discriminator,
567
+ commissioned: this.isCommissioned(),
568
+ };
569
+ await writeFile(commissioningFilePath, JSON.stringify(commissioningData, null, 2), 'utf-8');
570
+ log.debug(`Saved commissioning info to ${commissioningFilePath}`);
571
+ }
572
+ catch (error) {
573
+ log.warn(`Failed to save commissioning info to disk: ${error.message}`);
574
+ }
461
575
  // Display commissioning information
462
576
  log.info(`\n${'='.repeat(60)}`);
463
577
  log.info('📱 MATTER COMMISSIONING INFORMATION');
@@ -487,6 +601,115 @@ export class MatterServer {
487
601
  // Additional small delay to ensure everything is initialized
488
602
  await new Promise(resolve => setTimeout(resolve, SERVER_INIT_DELAY_MS));
489
603
  }
604
+ /**
605
+ * Start monitoring fabric changes to emit commissioning events
606
+ */
607
+ startFabricMonitoring() {
608
+ // Stop any existing monitor
609
+ this.stopFabricMonitoring();
610
+ // Initialize with current fabrics
611
+ const initialFabrics = this.getFabricInfo();
612
+ for (const fabric of initialFabrics) {
613
+ this.previousFabrics.set(fabric.fabricIndex, fabric);
614
+ }
615
+ log.debug('Starting fabric monitoring for commissioning events');
616
+ // Set up periodic monitoring
617
+ this.fabricMonitorInterval = setInterval(() => {
618
+ this.checkFabricChanges();
619
+ }, FABRIC_MONITOR_INTERVAL_MS);
620
+ // Add to clean up handlers
621
+ this.cleanupHandlers.push(() => this.stopFabricMonitoring());
622
+ }
623
+ /**
624
+ * Stop fabric monitoring
625
+ */
626
+ stopFabricMonitoring() {
627
+ if (this.fabricMonitorInterval) {
628
+ clearInterval(this.fabricMonitorInterval);
629
+ this.fabricMonitorInterval = null;
630
+ log.debug('Stopped fabric monitoring');
631
+ }
632
+ }
633
+ /**
634
+ * Check for fabric changes and emit appropriate events
635
+ */
636
+ checkFabricChanges() {
637
+ try {
638
+ const currentFabrics = this.getFabricInfo();
639
+ const currentFabricMap = new Map();
640
+ // Build map of current fabrics
641
+ for (const fabric of currentFabrics) {
642
+ currentFabricMap.set(fabric.fabricIndex, fabric);
643
+ }
644
+ const previousCount = this.previousFabrics.size;
645
+ const currentCount = currentFabricMap.size;
646
+ // Check for added fabrics
647
+ for (const [fabricIndex, fabric] of currentFabricMap) {
648
+ if (!this.previousFabrics.has(fabricIndex)) {
649
+ log.info(`Fabric added: ${fabric.fabricId} (index: ${fabricIndex})`);
650
+ this.emit('fabric-added', fabric);
651
+ // If this is the first fabric, emit 'commissioned' event
652
+ if (previousCount === 0 && currentCount === 1) {
653
+ log.info('Bridge commissioned for the first time');
654
+ this.emit('commissioned', fabric);
655
+ }
656
+ }
657
+ }
658
+ // Check for removed fabrics
659
+ for (const [fabricIndex, fabric] of this.previousFabrics) {
660
+ if (!currentFabricMap.has(fabricIndex)) {
661
+ log.info(`Fabric removed: ${fabric.fabricId} (index: ${fabricIndex})`);
662
+ this.emit('fabric-removed', fabric);
663
+ // If this was the last fabric, emit 'decommissioned' event
664
+ if (previousCount === 1 && currentCount === 0) {
665
+ log.info('Bridge decommissioned (last fabric removed)');
666
+ this.emit('decommissioned');
667
+ }
668
+ }
669
+ }
670
+ // Emit general commissioning-changed event if count changed
671
+ if (previousCount !== currentCount) {
672
+ const commissioned = currentCount > 0;
673
+ log.debug(`Commissioning state changed: commissioned=${commissioned}, fabricCount=${currentCount}`);
674
+ this.emit('commissioning-changed', commissioned, currentCount);
675
+ // Update commissioning info file
676
+ this.updateCommissioningFile().catch((error) => {
677
+ log.warn('Failed to update commissioning file:', error);
678
+ });
679
+ }
680
+ // Update previous fabrics map
681
+ this.previousFabrics = currentFabricMap;
682
+ }
683
+ catch (error) {
684
+ log.error('Error checking fabric changes:', error);
685
+ }
686
+ }
687
+ /**
688
+ * Update commissioning info file when commissioning state changes
689
+ */
690
+ async updateCommissioningFile() {
691
+ try {
692
+ if (!this.matterStoragePath) {
693
+ return;
694
+ }
695
+ const commissioningFilePath = path.join(this.matterStoragePath, 'commissioning.json');
696
+ const commissioningData = {
697
+ qrCode: this.commissioningInfo.qrCode,
698
+ manualPairingCode: this.commissioningInfo.manualPairingCode,
699
+ serialNumber: this.serialNumber,
700
+ passcode: this.passcode,
701
+ discriminator: this.discriminator,
702
+ commissioned: this.isCommissioned(),
703
+ fabricCount: this.getCommissionedFabricCount(),
704
+ fabrics: this.getFabricInfo(),
705
+ };
706
+ await writeFile(commissioningFilePath, JSON.stringify(commissioningData, null, 2), 'utf-8');
707
+ log.debug('Updated commissioning info file');
708
+ }
709
+ catch (error) {
710
+ log.debug(`Failed to update commissioning info file: ${error.message}`);
711
+ }
712
+ }
490
713
  /**
491
714
  * Register a Matter accessory (Plugin API)
492
715
  */
@@ -494,44 +717,127 @@ export class MatterServer {
494
717
  if (!this.serverNode || !this.aggregator) {
495
718
  throw new MatterDeviceError('Matter server not started');
496
719
  }
497
- // Validate required fields
720
+ // Validate required fields with helpful error messages
498
721
  if (!accessory.deviceType) {
499
- throw new MatterDeviceError(`Missing deviceType for accessory "${accessory.displayName}". `
500
- + 'Make sure you are using api.matterDeviceTypes (e.g., api.matterDeviceTypes.OnOffLight)');
722
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName || 'unknown'}" is missing required field 'deviceType'. `
723
+ + 'Example: deviceType: api.matterDeviceTypes.OnOffLight\n'
724
+ + 'Available device types: OnOffLight, DimmableLight, TemperatureSensor, etc.\n'
725
+ + 'See the Matter types documentation for the full list.');
501
726
  }
502
727
  if (!accessory.uuid) {
503
- throw new MatterDeviceError('Accessory must have a uuid');
728
+ throw new MatterDeviceError('Matter accessory is missing required field \'uuid\'.\n'
729
+ + 'Generate a unique UUID for your accessory:\n'
730
+ + ' const uuid = api.hap.uuid.generate(\'my-unique-id\')');
504
731
  }
505
732
  if (!accessory.displayName) {
506
- throw new MatterDeviceError('Accessory must have a displayName');
733
+ throw new MatterDeviceError(`Matter accessory (${accessory.uuid}) is missing required field 'displayName'.\n`
734
+ + 'Example: displayName: \'Living Room Light\'');
507
735
  }
508
736
  if (!accessory.serialNumber) {
509
- throw new MatterDeviceError(`Accessory "${accessory.displayName}" must have a serialNumber`);
737
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'serialNumber'.\n`
738
+ + 'Example: serialNumber: \'ABC123\' or serialNumber: accessory.UUID');
510
739
  }
511
740
  if (!accessory.manufacturer) {
512
- throw new MatterDeviceError(`Accessory "${accessory.displayName}" must have a manufacturer`);
741
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'manufacturer'.\n`
742
+ + 'Example: manufacturer: \'Homebridge\' or manufacturer: \'My Plugin Name\'');
513
743
  }
514
744
  if (!accessory.model) {
515
- throw new MatterDeviceError(`Accessory "${accessory.displayName}" must have a model`);
745
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'model'.\n`
746
+ + 'Example: model: \'v1.0\' or model: \'Smart Light\'');
516
747
  }
517
748
  if (!accessory.clusters || typeof accessory.clusters !== 'object') {
518
- throw new MatterDeviceError(`Accessory "${accessory.displayName}" must have clusters defined`);
749
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing or has invalid 'clusters' field.\n`
750
+ + 'Clusters define the functionality of your device. Example:\n'
751
+ + ' clusters: {\n'
752
+ + ' onOff: { onOff: false },\n'
753
+ + ' levelControl: { currentLevel: 0, minLevel: 0, maxLevel: 254 }\n'
754
+ + ' }');
519
755
  }
520
- // Check if already registered
756
+ // Check if already registered (during this session)
521
757
  if (this.accessories.has(accessory.uuid)) {
522
- throw new MatterDeviceError(`Accessory with UUID ${accessory.uuid} is already registered`);
758
+ const existing = this.accessories.get(accessory.uuid);
759
+ throw new MatterDeviceError(`Matter accessory with UUID "${accessory.uuid}" is already registered.\n`
760
+ + `Existing accessory: "${existing?.displayName}"\n`
761
+ + `New accessory: "${accessory.displayName}"\n`
762
+ + 'Each accessory must have a unique UUID. Use api.hap.uuid.generate() with a unique string.');
763
+ }
764
+ // Check if there's a cached version - merge cached cluster states with new registration
765
+ if (this.accessoryCache && this.accessoryCache.hasCached(accessory.uuid)) {
766
+ const cached = this.accessoryCache.getCached(accessory.uuid);
767
+ if (cached && cached.clusters) {
768
+ // Merge cached cluster states with new ones (prefer cached state to persist values across restarts)
769
+ for (const [clusterName, cachedAttrs] of Object.entries(cached.clusters)) {
770
+ if (!accessory.clusters[clusterName]) {
771
+ // Cluster exists in cache but not in new registration - preserve it
772
+ accessory.clusters[clusterName] = cachedAttrs;
773
+ }
774
+ else {
775
+ // Cluster exists in both - merge (prefer cached state over initial values)
776
+ accessory.clusters[clusterName] = {
777
+ ...accessory.clusters[clusterName],
778
+ ...cachedAttrs,
779
+ };
780
+ }
781
+ }
782
+ // Restore context if available
783
+ if (cached.context && !accessory.context) {
784
+ accessory.context = cached.context;
785
+ }
786
+ log.info(`Restored cached state for Matter accessory: ${accessory.displayName}`);
787
+ }
523
788
  }
524
789
  // Check device limit
525
790
  if (this.accessories.size >= this.MAX_DEVICES) {
526
- throw new MatterDeviceError(`Maximum device limit (${this.MAX_DEVICES}) reached`);
791
+ throw new MatterDeviceError(`Cannot register Matter accessory "${accessory.displayName}": `
792
+ + `Maximum device limit reached (${this.MAX_DEVICES} devices).\n`
793
+ + `Current registered devices: ${this.accessories.size}`);
527
794
  }
528
795
  try {
529
- // Create endpoint with device type
530
- const endpoint = new Endpoint(accessory.deviceType, {
796
+ // Modify device type with custom behaviors if handlers are defined
797
+ let deviceType = accessory.deviceType;
798
+ if (accessory.handlers) {
799
+ // Map cluster names to custom behavior classes
800
+ // Only clusters with user-triggered commands need custom behaviors
801
+ const behaviorMap = {
802
+ // Core controls
803
+ onOff: HomebridgeOnOffServer,
804
+ levelControl: HomebridgeLevelControlServer,
805
+ colorControl: HomebridgeColorControlServer,
806
+ // Coverings & locks
807
+ windowCovering: HomebridgeWindowCoveringServer,
808
+ doorLock: HomebridgeDoorLockServer,
809
+ // Climate control
810
+ thermostat: HomebridgeThermostatServer,
811
+ // Identification
812
+ identify: HomebridgeIdentifyServer,
813
+ };
814
+ // Build array of custom behaviors to apply based on what handlers are defined
815
+ const customBehaviors = [];
816
+ for (const clusterName of Object.keys(accessory.handlers)) {
817
+ const behaviorClass = behaviorMap[clusterName];
818
+ if (behaviorClass) {
819
+ customBehaviors.push(behaviorClass);
820
+ log.info(`Will use ${behaviorClass.name} for ${accessory.displayName}`);
821
+ }
822
+ else {
823
+ log.warn(`No custom behavior class available for cluster '${clusterName}' - handlers will be registered but may not be called`);
824
+ }
825
+ }
826
+ if (customBehaviors.length > 0) {
827
+ // Cast to any to bypass TypeScript limitations
828
+ deviceType = deviceType.with(...customBehaviors);
829
+ log.info(`Applied ${customBehaviors.length} custom behavior(s) to device type`);
830
+ }
831
+ }
832
+ // Create endpoint with the modified device type
833
+ const endpoint = new Endpoint(deviceType, {
531
834
  id: accessory.uuid,
532
835
  });
533
836
  // Add to aggregator FIRST (required before we can configure it)
534
837
  await this.aggregator.add(endpoint);
838
+ if (this.config.debugModeEnabled) {
839
+ log.debug(`Added endpoint for ${accessory.displayName} to aggregator`);
840
+ }
535
841
  // NOW configure the endpoint
536
842
  await this.configureEndpoint(endpoint, accessory);
537
843
  // Store accessory
@@ -542,6 +848,17 @@ export class MatterServer {
542
848
  };
543
849
  this.accessories.set(accessory.uuid, internalAccessory);
544
850
  log.info(`Registered Matter accessory: ${accessory.displayName} (${accessory.uuid})`);
851
+ if (this.config.debugModeEnabled) {
852
+ log.debug(`Total registered accessories: ${this.accessories.size}/${this.MAX_DEVICES}`);
853
+ }
854
+ // Emit accessory-registered event
855
+ this.emit('accessory-registered', accessory);
856
+ // Save to cache asynchronously (don't block registration)
857
+ if (this.accessoryCache) {
858
+ this.accessoryCache.save(this.accessories).catch((error) => {
859
+ log.warn('Failed to save accessory cache:', error);
860
+ });
861
+ }
545
862
  }
546
863
  catch (error) {
547
864
  log.error(`Failed to register Matter accessory ${accessory.displayName}:`, error);
@@ -564,6 +881,15 @@ export class MatterServer {
564
881
  }
565
882
  this.accessories.delete(uuid);
566
883
  log.info(`Unregistered Matter accessory: ${accessory.displayName} (${uuid})`);
884
+ // Emit accessory-unregistered event
885
+ this.emit('accessory-unregistered', uuid);
886
+ // Update cache (remove the accessory)
887
+ if (this.accessoryCache) {
888
+ this.accessoryCache.removeCached(uuid);
889
+ this.accessoryCache.save(this.accessories).catch((error) => {
890
+ log.warn('Failed to save accessory cache:', error);
891
+ });
892
+ }
567
893
  }
568
894
  catch (error) {
569
895
  log.error(`Failed to unregister Matter accessory ${uuid}:`, error);
@@ -572,27 +898,113 @@ export class MatterServer {
572
898
  }
573
899
  /**
574
900
  * Update a Matter accessory's state (Plugin API)
901
+ *
902
+ * This method can be called from anywhere, including from within handlers.
903
+ * State updates are automatically deferred to avoid transaction conflicts.
575
904
  */
576
905
  async updateAccessoryState(uuid, cluster, attributes) {
577
906
  const accessory = this.accessories.get(uuid);
578
907
  if (!accessory || !accessory.endpoint) {
579
908
  throw new MatterDeviceError(`Accessory ${uuid} not found or not registered`);
580
909
  }
910
+ // Defer the update to avoid "read-only transaction" errors when called from handlers
911
+ // Matter.js uses transactions, and we need to wait until the transaction fully completes
912
+ // Use setTimeout with a delay to ensure we're completely outside the transaction
913
+ return new Promise((resolve, reject) => {
914
+ setTimeout(async () => {
915
+ try {
916
+ // Use endpoint.set() method which is the proper way to update state
917
+ // This handles transactions correctly
918
+ const endpoint = accessory.endpoint;
919
+ // Construct the update object
920
+ const updateObject = { [cluster]: attributes };
921
+ // Use endpoint.set() which properly handles state updates
922
+ await endpoint.set(updateObject);
923
+ // CRITICAL: Also update the cached clusters object so state persists across restarts
924
+ // Merge the new attributes into the existing cluster state
925
+ if (!accessory.clusters[cluster]) {
926
+ accessory.clusters[cluster] = {};
927
+ }
928
+ accessory.clusters[cluster] = {
929
+ ...accessory.clusters[cluster],
930
+ ...attributes,
931
+ };
932
+ log.debug(`Updated ${cluster} state for ${accessory.displayName}:`, attributes);
933
+ resolve();
934
+ }
935
+ catch (error) {
936
+ log.error(`Failed to update state for accessory ${uuid}:`, error);
937
+ reject(new MatterDeviceError(`Failed to update accessory state: ${error}`));
938
+ }
939
+ }, 50); // 50ms delay to ensure we're completely outside the transaction
940
+ });
941
+ }
942
+ /**
943
+ * Get a Matter accessory's current state (Plugin API)
944
+ *
945
+ * Returns the current cluster attribute values that are exposed to Matter controllers.
946
+ * This is useful for:
947
+ * - Reading state after plugin restart (when local variables are lost)
948
+ * - Verifying current state before making changes
949
+ * - Multiple parts of code that need to read state
950
+ * - Debugging and logging
951
+ *
952
+ * @param uuid - The UUID of the accessory
953
+ * @param cluster - The cluster name (e.g., 'onOff', 'levelControl')
954
+ * @returns Current cluster attribute values, or undefined if cluster not found
955
+ */
956
+ getAccessoryState(uuid, cluster) {
957
+ const accessory = this.accessories.get(uuid);
958
+ if (!accessory || !accessory.endpoint) {
959
+ log.debug(`Accessory ${uuid} not found or not registered`);
960
+ return undefined;
961
+ }
581
962
  try {
582
- // Update the endpoint's cluster state
583
- // Note: Endpoint types from Matter.js don't expose state properly, needs runtime check
584
963
  const endpoint = accessory.endpoint;
585
- if (endpoint.state?.[cluster]) {
586
- Object.assign(endpoint.state[cluster], attributes);
587
- log.debug(`Updated ${cluster} state for ${accessory.displayName}:`, attributes);
964
+ if (!endpoint.state) {
965
+ log.debug(`endpoint.state is undefined for ${accessory.displayName}`);
966
+ return undefined;
967
+ }
968
+ if (!endpoint.state[cluster]) {
969
+ const availableClusters = Object.keys(endpoint.state || {});
970
+ log.debug(`Cluster '${cluster}' not found on ${accessory.displayName}. Available: ${availableClusters.join(', ')}`);
971
+ return undefined;
972
+ }
973
+ const clusterState = endpoint.state[cluster];
974
+ // Build result object by reading each property directly
975
+ const result = {};
976
+ // Get list of properties to read - use both approaches for maximum compatibility
977
+ const allKeys = new Set([
978
+ ...Object.keys(clusterState),
979
+ ...Object.getOwnPropertyNames(clusterState),
980
+ ]);
981
+ for (const key of allKeys) {
982
+ try {
983
+ // Skip internal properties, methods, and symbols
984
+ if (key.startsWith('_') || key.startsWith('$')) {
985
+ continue;
986
+ }
987
+ // Try to read the value directly
988
+ const value = clusterState[key];
989
+ // Skip functions and undefined values
990
+ if (typeof value === 'function' || value === undefined) {
991
+ continue;
992
+ }
993
+ result[key] = value;
994
+ }
995
+ catch (propError) {
996
+ log.debug(`Could not read property ${key} from ${cluster}:`, propError);
997
+ }
588
998
  }
589
- else {
590
- log.warn(`Cluster ${cluster} not found on accessory ${accessory.displayName}`);
999
+ if (Object.keys(result).length === 0) {
1000
+ log.debug(`Cluster ${cluster} found but no readable properties on accessory ${accessory.displayName}`);
1001
+ return undefined;
591
1002
  }
1003
+ return result;
592
1004
  }
593
1005
  catch (error) {
594
- log.error(`Failed to update state for accessory ${uuid}:`, error);
595
- throw new MatterDeviceError(`Failed to update accessory state: ${error}`);
1006
+ log.error(`Failed to get state for accessory ${uuid}:`, error);
1007
+ return undefined;
596
1008
  }
597
1009
  }
598
1010
  /**
@@ -651,12 +1063,12 @@ export class MatterServer {
651
1063
  }
652
1064
  // Set up command handlers if provided
653
1065
  if (accessory.handlers) {
1066
+ log.info(`Setting up handlers for accessory ${accessory.uuid}`);
1067
+ // Register handlers with the custom behavior classes
654
1068
  for (const [clusterName, handlers] of Object.entries(accessory.handlers)) {
1069
+ log.info(` Processing cluster: ${clusterName}`);
655
1070
  for (const [commandName, handler] of Object.entries(handlers)) {
656
- const endpointWithEvents = endpoint;
657
- if (endpointWithEvents.events?.[clusterName]?.[`${commandName}$Action`]) {
658
- endpointWithEvents.events[clusterName][`${commandName}$Action`].on(handler);
659
- }
1071
+ registerHandler(accessory.uuid, clusterName, commandName, handler);
660
1072
  }
661
1073
  }
662
1074
  }
@@ -671,9 +1083,14 @@ export class MatterServer {
671
1083
  }
672
1084
  this.isRunning = false;
673
1085
  // Stop monitoring
1086
+ this.stopFabricMonitoring();
674
1087
  networkMonitor.stopMonitoring();
675
- diagnostics.stopDiagnostics();
676
1088
  try {
1089
+ // Save accessory cache before shutting down (BEFORE clearing accessories!)
1090
+ if (this.accessoryCache && this.accessories.size > 0) {
1091
+ await this.accessoryCache.save(this.accessories);
1092
+ log.debug('Saved accessory cache before shutdown');
1093
+ }
677
1094
  // Clean up all accessories
678
1095
  for (const accessory of this.accessories.values()) {
679
1096
  try {
@@ -820,5 +1237,40 @@ export class MatterServer {
820
1237
  getClusters() {
821
1238
  return clusters;
822
1239
  }
1240
+ /**
1241
+ * Remove a specific fabric (controller) from the bridge
1242
+ * This decommissions a single controller while leaving others intact
1243
+ *
1244
+ * @param fabricIndex - The fabric index to remove
1245
+ * @returns Promise that resolves when the fabric is removed
1246
+ */
1247
+ async removeFabric(fabricIndex) {
1248
+ if (!this.serverNode) {
1249
+ throw new MatterDeviceError('Matter server not started');
1250
+ }
1251
+ try {
1252
+ log.info(`Removing fabric ${fabricIndex}...`);
1253
+ const serverState = this.serverNode;
1254
+ const removeFabric = serverState?.state?.commissioning?.removeFabric;
1255
+ if (typeof removeFabric !== 'function') {
1256
+ throw new MatterDeviceError('Fabric removal not supported by Matter.js version');
1257
+ }
1258
+ // Remove the fabric
1259
+ await removeFabric(fabricIndex);
1260
+ log.info(`Fabric ${fabricIndex} removed successfully`);
1261
+ // The fabric monitoring will detect this change and emit the appropriate events
1262
+ }
1263
+ catch (error) {
1264
+ log.error(`Failed to remove fabric ${fabricIndex}:`, error);
1265
+ throw new MatterDeviceError(`Failed to remove fabric: ${error.message}`, error);
1266
+ }
1267
+ }
1268
+ /**
1269
+ * Check if a specific fabric exists
1270
+ */
1271
+ hasFabric(fabricIndex) {
1272
+ const fabrics = this.getFabricInfo();
1273
+ return fabrics.some(f => f.fabricIndex === fabricIndex);
1274
+ }
823
1275
  }
824
1276
  //# sourceMappingURL=matterServer.js.map