homebridge 2.0.0-alpha.43 → 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 (76) hide show
  1. package/dist/api.d.ts +30 -0
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +26 -0
  4. package/dist/api.js.map +1 -1
  5. package/dist/bridgeService.d.ts +3 -13
  6. package/dist/bridgeService.d.ts.map +1 -1
  7. package/dist/bridgeService.js +8 -24
  8. package/dist/bridgeService.js.map +1 -1
  9. package/dist/childBridgeFork.d.ts +0 -8
  10. package/dist/childBridgeFork.d.ts.map +1 -1
  11. package/dist/childBridgeFork.js +26 -127
  12. package/dist/childBridgeFork.js.map +1 -1
  13. package/dist/childBridgeService.d.ts +0 -12
  14. package/dist/childBridgeService.d.ts.map +1 -1
  15. package/dist/childBridgeService.js +9 -25
  16. package/dist/childBridgeService.js.map +1 -1
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +1 -3
  19. package/dist/cli.js.map +1 -1
  20. package/dist/ipcService.d.ts +2 -12
  21. package/dist/ipcService.d.ts.map +1 -1
  22. package/dist/ipcService.js +0 -7
  23. package/dist/ipcService.js.map +1 -1
  24. package/dist/matter/index.d.ts +1 -1
  25. package/dist/matter/index.d.ts.map +1 -1
  26. package/dist/matter/index.js.map +1 -1
  27. package/dist/matter/matterAccessoryCache.d.ts +73 -0
  28. package/dist/matter/matterAccessoryCache.d.ts.map +1 -0
  29. package/dist/matter/matterAccessoryCache.js +168 -0
  30. package/dist/matter/matterAccessoryCache.js.map +1 -0
  31. package/dist/matter/matterBehaviors.d.ts +123 -0
  32. package/dist/matter/matterBehaviors.d.ts.map +1 -0
  33. package/dist/matter/matterBehaviors.js +582 -0
  34. package/dist/matter/matterBehaviors.js.map +1 -0
  35. package/dist/matter/matterConfigValidator.d.ts +0 -1
  36. package/dist/matter/matterConfigValidator.d.ts.map +1 -1
  37. package/dist/matter/matterConfigValidator.js +15 -45
  38. package/dist/matter/matterConfigValidator.js.map +1 -1
  39. package/dist/matter/matterErrorHandler.d.ts +1 -1
  40. package/dist/matter/matterErrorHandler.d.ts.map +1 -1
  41. package/dist/matter/matterErrorHandler.js +35 -22
  42. package/dist/matter/matterErrorHandler.js.map +1 -1
  43. package/dist/matter/matterNetworkMonitor.d.ts +3 -0
  44. package/dist/matter/matterNetworkMonitor.d.ts.map +1 -1
  45. package/dist/matter/matterNetworkMonitor.js +49 -26
  46. package/dist/matter/matterNetworkMonitor.js.map +1 -1
  47. package/dist/matter/matterServer.d.ts +79 -5
  48. package/dist/matter/matterServer.d.ts.map +1 -1
  49. package/dist/matter/matterServer.js +487 -97
  50. package/dist/matter/matterServer.js.map +1 -1
  51. package/dist/matter/matterSharedTypes.d.ts +12 -4
  52. package/dist/matter/matterSharedTypes.d.ts.map +1 -1
  53. package/dist/matter/matterSharedTypes.js.map +1 -1
  54. package/dist/matter/matterStorage.d.ts +11 -1
  55. package/dist/matter/matterStorage.d.ts.map +1 -1
  56. package/dist/matter/matterStorage.js +12 -2
  57. package/dist/matter/matterStorage.js.map +1 -1
  58. package/dist/matter/matterTypes.d.ts +68 -19
  59. package/dist/matter/matterTypes.d.ts.map +1 -1
  60. package/dist/matter/matterTypes.js.map +1 -1
  61. package/dist/matter/matterValidation.d.ts +57 -0
  62. package/dist/matter/matterValidation.d.ts.map +1 -0
  63. package/dist/matter/matterValidation.js +100 -0
  64. package/dist/matter/matterValidation.js.map +1 -0
  65. package/dist/plugin.d.ts.map +1 -1
  66. package/dist/plugin.js +2 -4
  67. package/dist/plugin.js.map +1 -1
  68. package/dist/server.d.ts +0 -7
  69. package/dist/server.d.ts.map +1 -1
  70. package/dist/server.js +80 -112
  71. package/dist/server.js.map +1 -1
  72. package/package.json +3 -3
  73. package/dist/matter/matterDiagnostics.d.ts +0 -121
  74. package/dist/matter/matterDiagnostics.d.ts.map +0 -1
  75. package/dist/matter/matterDiagnostics.js +0 -323
  76. 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
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;
@@ -54,19 +60,87 @@ export class MatterServer {
54
60
  cleanupHandlers = [];
55
61
  storageManager = null;
56
62
  matterStoragePath;
57
- constructor(config = {}) {
58
- this.config = config;
59
- // Store the user config with defaults
60
- this.config = {
61
- port: config.port || DEFAULT_MATTER_PORT,
62
- name: config.name || 'Homebridge Matter Bridge',
63
- // Use a consistent uniqueId based on the name to ensure storage persistence
64
- uniqueId: config.uniqueId || `homebridge-matter-${config.name?.replace(/[^a-z0-9]/gi, '-') || 'bridge'}`,
65
- storagePath: config.storagePath,
66
- };
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
+ }
67
75
  // Initialize commissioning values (will be loaded from storage in start())
68
76
  this.vendorId = DEFAULT_VENDOR_ID;
69
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
+ };
70
144
  }
71
145
  /**
72
146
  * Generate a secure random passcode
@@ -167,8 +241,21 @@ export class MatterServer {
167
241
  }
168
242
  /**
169
243
  * Create ServerNode with automatic recovery from corrupted storage
170
- * If ServerNode creation fails due to corrupted fabric data, automatically
171
- * 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
172
259
  */
173
260
  async createServerNodeWithRecovery(nodeOptions, sanitizedId) {
174
261
  try {
@@ -248,17 +335,14 @@ export class MatterServer {
248
335
  // Start network monitoring
249
336
  networkMonitor.startMonitoring();
250
337
  this.cleanupHandlers.push(() => networkMonitor.stopMonitoring());
251
- // Start diagnostics
252
- diagnostics.startDiagnostics();
253
- this.cleanupHandlers.push(() => diagnostics.stopDiagnostics());
254
338
  // Create commissioning options
255
339
  const commissioningOptions = {
256
340
  passcode: this.passcode,
257
341
  discriminator: this.discriminator,
258
342
  };
259
343
  log.info(`Using commissioning credentials: passcode=${this.passcode}, discriminator=${this.discriminator}`);
260
- // Ensure we have a name for the bridge
261
- const bridgeName = this.config.name || 'Homebridge Matter Bridge';
344
+ // Use default name for the Matter bridge
345
+ const bridgeName = 'Homebridge Matter Bridge';
262
346
  // Sanitize the uniqueId to ensure it's filesystem-safe
263
347
  // Replace any characters that could cause issues (colons, slashes, etc.)
264
348
  // Use only alphanumeric and hyphens, collapse multiple hyphens, trim leading/trailing hyphens
@@ -278,15 +362,15 @@ export class MatterServer {
278
362
  basicInformation: {
279
363
  nodeLabel: bridgeName.slice(0, 32), // Maximum 32 characters
280
364
  vendorId: VendorId(this.vendorId),
281
- vendorName: 'Homebridge'.slice(0, 32),
365
+ vendorName: (this.config.manufacturer || 'Homebridge').slice(0, 32),
282
366
  productId: this.productId,
283
- productName: 'Homebridge Matter Bridge'.slice(0, 32),
367
+ productName: (this.config.model || 'Homebridge Matter Bridge').slice(0, 32),
284
368
  productLabel: bridgeName.slice(0, 64), // Maximum 64 characters
285
- serialNumber: this.serialNumber = this.generateSerialNumber(),
369
+ serialNumber: this.serialNumber = this.config.serialNumber || this.generateSerialNumber(),
286
370
  hardwareVersion: 1,
287
371
  hardwareVersionString: os.release(),
288
372
  softwareVersion: 1,
289
- softwareVersionString: getVersion(),
373
+ softwareVersionString: this.config.firmwareRevision || getVersion(),
290
374
  reachable: true,
291
375
  },
292
376
  };
@@ -317,6 +401,12 @@ export class MatterServer {
317
401
  });
318
402
  // Wait for server to be ready
319
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();
320
410
  this.isRunning = true;
321
411
  log.info(`Matter server started successfully on port ${this.config.port}`);
322
412
  log.info('Plugins can now register Matter accessories via the API');
@@ -362,6 +452,8 @@ export class MatterServer {
362
452
  await fse.ensureDir(this.matterStoragePath);
363
453
  // Create storage manager
364
454
  this.storageManager = new MatterStorageManager(this.matterStoragePath);
455
+ // Create accessory cache
456
+ this.accessoryCache = new MatterAccessoryCache(normalizedPath, bridgeId);
365
457
  // Configure environment to use our custom storage
366
458
  const environment = Environment.default;
367
459
  const storageService = environment.get(StorageService);
@@ -396,7 +488,8 @@ export class MatterServer {
396
488
  if (!this.storageManager) {
397
489
  throw new Error('Storage manager not initialized');
398
490
  }
399
- const storage = this.storageManager.getStorage('commissioning');
491
+ // Use 'credentials' namespace
492
+ const storage = this.storageManager.getStorage('credentials');
400
493
  // CRITICAL: Initialize storage before reading to avoid race condition
401
494
  await storage.initialize();
402
495
  // Try to load existing credentials
@@ -508,6 +601,115 @@ export class MatterServer {
508
601
  // Additional small delay to ensure everything is initialized
509
602
  await new Promise(resolve => setTimeout(resolve, SERVER_INIT_DELAY_MS));
510
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
+ }
511
713
  /**
512
714
  * Register a Matter accessory (Plugin API)
513
715
  */
@@ -515,44 +717,127 @@ export class MatterServer {
515
717
  if (!this.serverNode || !this.aggregator) {
516
718
  throw new MatterDeviceError('Matter server not started');
517
719
  }
518
- // Validate required fields
720
+ // Validate required fields with helpful error messages
519
721
  if (!accessory.deviceType) {
520
- throw new MatterDeviceError(`Missing deviceType for accessory "${accessory.displayName}". `
521
- + '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.');
522
726
  }
523
727
  if (!accessory.uuid) {
524
- 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\')');
525
731
  }
526
732
  if (!accessory.displayName) {
527
- 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\'');
528
735
  }
529
736
  if (!accessory.serialNumber) {
530
- 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');
531
739
  }
532
740
  if (!accessory.manufacturer) {
533
- 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\'');
534
743
  }
535
744
  if (!accessory.model) {
536
- 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\'');
537
747
  }
538
748
  if (!accessory.clusters || typeof accessory.clusters !== 'object') {
539
- 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
+ + ' }');
540
755
  }
541
- // Check if already registered
756
+ // Check if already registered (during this session)
542
757
  if (this.accessories.has(accessory.uuid)) {
543
- 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
+ }
544
788
  }
545
789
  // Check device limit
546
790
  if (this.accessories.size >= this.MAX_DEVICES) {
547
- 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}`);
548
794
  }
549
795
  try {
550
- // Create endpoint with device type
551
- 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, {
552
834
  id: accessory.uuid,
553
835
  });
554
836
  // Add to aggregator FIRST (required before we can configure it)
555
837
  await this.aggregator.add(endpoint);
838
+ if (this.config.debugModeEnabled) {
839
+ log.debug(`Added endpoint for ${accessory.displayName} to aggregator`);
840
+ }
556
841
  // NOW configure the endpoint
557
842
  await this.configureEndpoint(endpoint, accessory);
558
843
  // Store accessory
@@ -563,6 +848,17 @@ export class MatterServer {
563
848
  };
564
849
  this.accessories.set(accessory.uuid, internalAccessory);
565
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
+ }
566
862
  }
567
863
  catch (error) {
568
864
  log.error(`Failed to register Matter accessory ${accessory.displayName}:`, error);
@@ -585,6 +881,15 @@ export class MatterServer {
585
881
  }
586
882
  this.accessories.delete(uuid);
587
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
+ }
588
893
  }
589
894
  catch (error) {
590
895
  log.error(`Failed to unregister Matter accessory ${uuid}:`, error);
@@ -593,27 +898,113 @@ export class MatterServer {
593
898
  }
594
899
  /**
595
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.
596
904
  */
597
905
  async updateAccessoryState(uuid, cluster, attributes) {
598
906
  const accessory = this.accessories.get(uuid);
599
907
  if (!accessory || !accessory.endpoint) {
600
908
  throw new MatterDeviceError(`Accessory ${uuid} not found or not registered`);
601
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
+ }
602
962
  try {
603
- // Update the endpoint's cluster state
604
- // Note: Endpoint types from Matter.js don't expose state properly, needs runtime check
605
963
  const endpoint = accessory.endpoint;
606
- if (endpoint.state?.[cluster]) {
607
- Object.assign(endpoint.state[cluster], attributes);
608
- 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;
609
967
  }
610
- else {
611
- log.warn(`Cluster ${cluster} not found on accessory ${accessory.displayName}`);
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;
612
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
+ }
998
+ }
999
+ if (Object.keys(result).length === 0) {
1000
+ log.debug(`Cluster ${cluster} found but no readable properties on accessory ${accessory.displayName}`);
1001
+ return undefined;
1002
+ }
1003
+ return result;
613
1004
  }
614
1005
  catch (error) {
615
- log.error(`Failed to update state for accessory ${uuid}:`, error);
616
- throw new MatterDeviceError(`Failed to update accessory state: ${error}`);
1006
+ log.error(`Failed to get state for accessory ${uuid}:`, error);
1007
+ return undefined;
617
1008
  }
618
1009
  }
619
1010
  /**
@@ -673,54 +1064,13 @@ export class MatterServer {
673
1064
  // Set up command handlers if provided
674
1065
  if (accessory.handlers) {
675
1066
  log.info(`Setting up handlers for accessory ${accessory.uuid}`);
676
- // Use endpoint.act() to access behavior instances
677
- await endpoint.act('setup-handlers', async (agent) => {
678
- log.info(` Inside act() - agent type: ${typeof agent}`);
679
- log.info(` Agent keys: ${Object.keys(agent).join(', ')}`);
680
- for (const [clusterName, handlers] of Object.entries(accessory.handlers)) {
681
- log.info(` Processing cluster: ${clusterName}`);
682
- // Try to access the behavior on the agent
683
- const behavior = agent[clusterName];
684
- if (!behavior) {
685
- log.warn(` ✗ Behavior '${clusterName}' not found on agent`);
686
- log.warn(' Available behaviors:', Object.keys(agent).filter(k => typeof agent[k] === 'object'));
687
- continue;
688
- }
689
- log.info(` ✓ Found behavior: ${clusterName}`);
690
- log.info(` Behavior type: ${typeof behavior}`);
691
- log.info(` Behavior constructor: ${behavior?.constructor?.name}`);
692
- // Get all methods on the behavior
693
- const behaviorMethods = Object.keys(behavior).filter(k => typeof behavior[k] === 'function');
694
- log.info(` Available methods: ${behaviorMethods.join(', ')}`);
695
- for (const [commandName, handler] of Object.entries(handlers)) {
696
- log.info(` Processing command: ${commandName}`);
697
- // Store the original method if it exists
698
- const originalMethod = behavior[commandName];
699
- if (typeof originalMethod !== 'function') {
700
- log.warn(` ✗ Method '${commandName}' not found on ${clusterName} behavior`);
701
- continue;
702
- }
703
- log.info(` ✓ Found method '${commandName}', wrapping with custom handler`);
704
- // Override the behavior method with our handler
705
- behavior[commandName] = async function (...args) {
706
- log.info(` ┌─ HANDLER CALLED: ${clusterName}.${commandName}`);
707
- log.info(` │ Args: ${JSON.stringify(args)}`);
708
- try {
709
- await handler(...args);
710
- log.info(' │ Custom handler completed successfully');
711
- }
712
- catch (error) {
713
- log.error(' │ Error in custom handler:', error);
714
- }
715
- // Call the original method to maintain default behavior
716
- const result = await originalMethod.call(this, ...args);
717
- log.info(` └─ Original method returned: ${JSON.stringify(result)}`);
718
- return result;
719
- };
720
- log.info(` ✓ Registered handler for ${clusterName}.${commandName}`);
721
- }
1067
+ // Register handlers with the custom behavior classes
1068
+ for (const [clusterName, handlers] of Object.entries(accessory.handlers)) {
1069
+ log.info(` Processing cluster: ${clusterName}`);
1070
+ for (const [commandName, handler] of Object.entries(handlers)) {
1071
+ registerHandler(accessory.uuid, clusterName, commandName, handler);
722
1072
  }
723
- });
1073
+ }
724
1074
  }
725
1075
  }
726
1076
  /**
@@ -733,9 +1083,14 @@ export class MatterServer {
733
1083
  }
734
1084
  this.isRunning = false;
735
1085
  // Stop monitoring
1086
+ this.stopFabricMonitoring();
736
1087
  networkMonitor.stopMonitoring();
737
- diagnostics.stopDiagnostics();
738
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
+ }
739
1094
  // Clean up all accessories
740
1095
  for (const accessory of this.accessories.values()) {
741
1096
  try {
@@ -882,5 +1237,40 @@ export class MatterServer {
882
1237
  getClusters() {
883
1238
  return clusters;
884
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
+ }
885
1275
  }
886
1276
  //# sourceMappingURL=matterServer.js.map