homebridge 2.0.0-alpha.43 → 2.0.0-alpha.45

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 (85) hide show
  1. package/dist/api.d.ts +37 -7
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +30 -4
  4. package/dist/api.js.map +1 -1
  5. package/dist/bridgeService.d.ts +5 -16
  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 +3 -15
  10. package/dist/childBridgeFork.d.ts.map +1 -1
  11. package/dist/childBridgeFork.js +46 -181
  12. package/dist/childBridgeFork.js.map +1 -1
  13. package/dist/childBridgeService.d.ts +20 -43
  14. package/dist/childBridgeService.d.ts.map +1 -1
  15. package/dist/childBridgeService.js +23 -66
  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/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/ipcService.d.ts +23 -21
  25. package/dist/ipcService.d.ts.map +1 -1
  26. package/dist/ipcService.js +0 -15
  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 +79 -9
  52. package/dist/matter/matterServer.d.ts.map +1 -1
  53. package/dist/matter/matterServer.js +491 -111
  54. package/dist/matter/matterServer.js.map +1 -1
  55. package/dist/matter/matterSharedTypes.d.ts +36 -16
  56. package/dist/matter/matterSharedTypes.d.ts.map +1 -1
  57. package/dist/matter/matterSharedTypes.js +0 -3
  58. package/dist/matter/matterSharedTypes.js.map +1 -1
  59. package/dist/matter/matterStorage.d.ts +11 -1
  60. package/dist/matter/matterStorage.d.ts.map +1 -1
  61. package/dist/matter/matterStorage.js +12 -2
  62. package/dist/matter/matterStorage.js.map +1 -1
  63. package/dist/matter/matterTypes.d.ts +69 -20
  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 +97 -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 -12
  74. package/dist/server.d.ts.map +1 -1
  75. package/dist/server.js +97 -280
  76. package/dist/server.js.map +1 -1
  77. package/package.json +3 -3
  78. package/dist/bridgeTypes.d.ts +0 -54
  79. package/dist/bridgeTypes.d.ts.map +0 -1
  80. package/dist/bridgeTypes.js +0 -8
  81. package/dist/bridgeTypes.js.map +0 -1
  82. package/dist/matter/matterDiagnostics.d.ts +0 -121
  83. package/dist/matter/matterDiagnostics.d.ts.map +0 -1
  84. package/dist/matter/matterDiagnostics.js +0 -323
  85. 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,21 +335,16 @@ 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';
262
- // Sanitize the uniqueId to ensure it's filesystem-safe
263
- // Replace any characters that could cause issues (colons, slashes, etc.)
264
- // Use only alphanumeric and hyphens, collapse multiple hyphens, trim leading/trailing hyphens
265
- const sanitizedId = this.config.uniqueId.replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
344
+ // Use default name for the Matter bridge
345
+ const bridgeName = 'Homebridge Matter Bridge';
346
+ // uniqueId is already sanitized in validateAndSanitizeConfig()
347
+ const sanitizedId = this.config.uniqueId;
266
348
  // Create node options with proper typing
267
349
  const nodeOptions = {
268
350
  id: sanitizedId,
@@ -278,15 +360,15 @@ export class MatterServer {
278
360
  basicInformation: {
279
361
  nodeLabel: bridgeName.slice(0, 32), // Maximum 32 characters
280
362
  vendorId: VendorId(this.vendorId),
281
- vendorName: 'Homebridge'.slice(0, 32),
363
+ vendorName: (this.config.manufacturer || 'Homebridge').slice(0, 32),
282
364
  productId: this.productId,
283
- productName: 'Homebridge Matter Bridge'.slice(0, 32),
365
+ productName: (this.config.model || 'Homebridge Matter Bridge').slice(0, 32),
284
366
  productLabel: bridgeName.slice(0, 64), // Maximum 64 characters
285
- serialNumber: this.serialNumber = this.generateSerialNumber(),
367
+ serialNumber: this.serialNumber = this.config.serialNumber || this.config.uniqueId,
286
368
  hardwareVersion: 1,
287
369
  hardwareVersionString: os.release(),
288
370
  softwareVersion: 1,
289
- softwareVersionString: getVersion(),
371
+ softwareVersionString: this.config.firmwareRevision || getVersion(),
290
372
  reachable: true,
291
373
  },
292
374
  };
@@ -317,6 +399,12 @@ export class MatterServer {
317
399
  });
318
400
  // Wait for server to be ready
319
401
  await this.waitForServerReady();
402
+ // Load cached accessories (don't restore them yet - wait for plugins to re-register)
403
+ if (this.accessoryCache) {
404
+ await this.accessoryCache.load();
405
+ }
406
+ // Start fabric monitoring to emit commissioning events
407
+ this.startFabricMonitoring();
320
408
  this.isRunning = true;
321
409
  log.info(`Matter server started successfully on port ${this.config.port}`);
322
410
  log.info('Plugins can now register Matter accessories via the API');
@@ -356,12 +444,14 @@ export class MatterServer {
356
444
  throw new Error(`Storage path not accessible: ${error}`);
357
445
  }
358
446
  // Create bridge-specific storage directory
359
- // Use only alphanumeric characters and hyphens for maximum compatibility
360
- const bridgeId = this.config.uniqueId?.replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'default';
447
+ // uniqueId is already sanitized in validateAndSanitizeConfig()
448
+ const bridgeId = this.config.uniqueId || 'default';
361
449
  this.matterStoragePath = path.join(normalizedPath, bridgeId);
362
450
  await fse.ensureDir(this.matterStoragePath);
363
451
  // Create storage manager
364
452
  this.storageManager = new MatterStorageManager(this.matterStoragePath);
453
+ // Create accessory cache
454
+ this.accessoryCache = new MatterAccessoryCache(normalizedPath, bridgeId);
365
455
  // Configure environment to use our custom storage
366
456
  const environment = Environment.default;
367
457
  const storageService = environment.get(StorageService);
@@ -396,7 +486,8 @@ export class MatterServer {
396
486
  if (!this.storageManager) {
397
487
  throw new Error('Storage manager not initialized');
398
488
  }
399
- const storage = this.storageManager.getStorage('commissioning');
489
+ // Use 'credentials' namespace
490
+ const storage = this.storageManager.getStorage('credentials');
400
491
  // CRITICAL: Initialize storage before reading to avoid race condition
401
492
  await storage.initialize();
402
493
  // Try to load existing credentials
@@ -419,14 +510,6 @@ export class MatterServer {
419
510
  log.info('Commissioning credentials saved to storage');
420
511
  }
421
512
  }
422
- /**
423
- * Generate serial number for the bridge
424
- */
425
- generateSerialNumber() {
426
- const timestamp = Date.now().toString(36).toUpperCase();
427
- const random = crypto.randomBytes(2).toString('hex').toUpperCase();
428
- return `HB-${timestamp}-${random}`;
429
- }
430
513
  /**
431
514
  * Generate and display commissioning information
432
515
  */
@@ -508,6 +591,115 @@ export class MatterServer {
508
591
  // Additional small delay to ensure everything is initialized
509
592
  await new Promise(resolve => setTimeout(resolve, SERVER_INIT_DELAY_MS));
510
593
  }
594
+ /**
595
+ * Start monitoring fabric changes to emit commissioning events
596
+ */
597
+ startFabricMonitoring() {
598
+ // Stop any existing monitor
599
+ this.stopFabricMonitoring();
600
+ // Initialize with current fabrics
601
+ const initialFabrics = this.getFabricInfo();
602
+ for (const fabric of initialFabrics) {
603
+ this.previousFabrics.set(fabric.fabricIndex, fabric);
604
+ }
605
+ log.debug('Starting fabric monitoring for commissioning events');
606
+ // Set up periodic monitoring
607
+ this.fabricMonitorInterval = setInterval(() => {
608
+ this.checkFabricChanges();
609
+ }, FABRIC_MONITOR_INTERVAL_MS);
610
+ // Add to clean up handlers
611
+ this.cleanupHandlers.push(() => this.stopFabricMonitoring());
612
+ }
613
+ /**
614
+ * Stop fabric monitoring
615
+ */
616
+ stopFabricMonitoring() {
617
+ if (this.fabricMonitorInterval) {
618
+ clearInterval(this.fabricMonitorInterval);
619
+ this.fabricMonitorInterval = null;
620
+ log.debug('Stopped fabric monitoring');
621
+ }
622
+ }
623
+ /**
624
+ * Check for fabric changes and emit appropriate events
625
+ */
626
+ checkFabricChanges() {
627
+ try {
628
+ const currentFabrics = this.getFabricInfo();
629
+ const currentFabricMap = new Map();
630
+ // Build map of current fabrics
631
+ for (const fabric of currentFabrics) {
632
+ currentFabricMap.set(fabric.fabricIndex, fabric);
633
+ }
634
+ const previousCount = this.previousFabrics.size;
635
+ const currentCount = currentFabricMap.size;
636
+ // Check for added fabrics
637
+ for (const [fabricIndex, fabric] of currentFabricMap) {
638
+ if (!this.previousFabrics.has(fabricIndex)) {
639
+ log.info(`Fabric added: ${fabric.fabricId} (index: ${fabricIndex})`);
640
+ this.emit('fabric-added', fabric);
641
+ // If this is the first fabric, emit 'commissioned' event
642
+ if (previousCount === 0 && currentCount === 1) {
643
+ log.info('Bridge commissioned for the first time');
644
+ this.emit('commissioned', fabric);
645
+ }
646
+ }
647
+ }
648
+ // Check for removed fabrics
649
+ for (const [fabricIndex, fabric] of this.previousFabrics) {
650
+ if (!currentFabricMap.has(fabricIndex)) {
651
+ log.info(`Fabric removed: ${fabric.fabricId} (index: ${fabricIndex})`);
652
+ this.emit('fabric-removed', fabric);
653
+ // If this was the last fabric, emit 'decommissioned' event
654
+ if (previousCount === 1 && currentCount === 0) {
655
+ log.info('Bridge decommissioned (last fabric removed)');
656
+ this.emit('decommissioned');
657
+ }
658
+ }
659
+ }
660
+ // Emit general commissioning-changed event if count changed
661
+ if (previousCount !== currentCount) {
662
+ const commissioned = currentCount > 0;
663
+ log.debug(`Commissioning state changed: commissioned=${commissioned}, fabricCount=${currentCount}`);
664
+ this.emit('commissioning-changed', commissioned, currentCount);
665
+ // Update commissioning info file
666
+ this.updateCommissioningFile().catch((error) => {
667
+ log.warn('Failed to update commissioning file:', error);
668
+ });
669
+ }
670
+ // Update previous fabrics map
671
+ this.previousFabrics = currentFabricMap;
672
+ }
673
+ catch (error) {
674
+ log.error('Error checking fabric changes:', error);
675
+ }
676
+ }
677
+ /**
678
+ * Update commissioning info file when commissioning state changes
679
+ */
680
+ async updateCommissioningFile() {
681
+ try {
682
+ if (!this.matterStoragePath) {
683
+ return;
684
+ }
685
+ const commissioningFilePath = path.join(this.matterStoragePath, 'commissioning.json');
686
+ const commissioningData = {
687
+ qrCode: this.commissioningInfo.qrCode,
688
+ manualPairingCode: this.commissioningInfo.manualPairingCode,
689
+ serialNumber: this.serialNumber,
690
+ passcode: this.passcode,
691
+ discriminator: this.discriminator,
692
+ commissioned: this.isCommissioned(),
693
+ fabricCount: this.getCommissionedFabricCount(),
694
+ fabrics: this.getFabricInfo(),
695
+ };
696
+ await writeFile(commissioningFilePath, JSON.stringify(commissioningData, null, 2), 'utf-8');
697
+ log.debug('Updated commissioning info file');
698
+ }
699
+ catch (error) {
700
+ log.debug(`Failed to update commissioning info file: ${error.message}`);
701
+ }
702
+ }
511
703
  /**
512
704
  * Register a Matter accessory (Plugin API)
513
705
  */
@@ -515,44 +707,127 @@ export class MatterServer {
515
707
  if (!this.serverNode || !this.aggregator) {
516
708
  throw new MatterDeviceError('Matter server not started');
517
709
  }
518
- // Validate required fields
710
+ // Validate required fields with helpful error messages
519
711
  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)');
712
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName || 'unknown'}" is missing required field 'deviceType'. `
713
+ + 'Example: deviceType: api.matterDeviceTypes.OnOffLight\n'
714
+ + 'Available device types: OnOffLight, DimmableLight, TemperatureSensor, etc.\n'
715
+ + 'See the Matter types documentation for the full list.');
522
716
  }
523
717
  if (!accessory.uuid) {
524
- throw new MatterDeviceError('Accessory must have a uuid');
718
+ throw new MatterDeviceError('Matter accessory is missing required field \'uuid\'.\n'
719
+ + 'Generate a unique UUID for your accessory:\n'
720
+ + ' const uuid = api.hap.uuid.generate(\'my-unique-id\')');
525
721
  }
526
722
  if (!accessory.displayName) {
527
- throw new MatterDeviceError('Accessory must have a displayName');
723
+ throw new MatterDeviceError(`Matter accessory (${accessory.uuid}) is missing required field 'displayName'.\n`
724
+ + 'Example: displayName: \'Living Room Light\'');
528
725
  }
529
726
  if (!accessory.serialNumber) {
530
- throw new MatterDeviceError(`Accessory "${accessory.displayName}" must have a serialNumber`);
727
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'serialNumber'.\n`
728
+ + 'Example: serialNumber: \'ABC123\' or serialNumber: accessory.UUID');
531
729
  }
532
730
  if (!accessory.manufacturer) {
533
- throw new MatterDeviceError(`Accessory "${accessory.displayName}" must have a manufacturer`);
731
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'manufacturer'.\n`
732
+ + 'Example: manufacturer: \'Homebridge\' or manufacturer: \'My Plugin Name\'');
534
733
  }
535
734
  if (!accessory.model) {
536
- throw new MatterDeviceError(`Accessory "${accessory.displayName}" must have a model`);
735
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'model'.\n`
736
+ + 'Example: model: \'v1.0\' or model: \'Smart Light\'');
537
737
  }
538
738
  if (!accessory.clusters || typeof accessory.clusters !== 'object') {
539
- throw new MatterDeviceError(`Accessory "${accessory.displayName}" must have clusters defined`);
739
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing or has invalid 'clusters' field.\n`
740
+ + 'Clusters define the functionality of your device. Example:\n'
741
+ + ' clusters: {\n'
742
+ + ' onOff: { onOff: false },\n'
743
+ + ' levelControl: { currentLevel: 0, minLevel: 0, maxLevel: 254 }\n'
744
+ + ' }');
540
745
  }
541
- // Check if already registered
746
+ // Check if already registered (during this session)
542
747
  if (this.accessories.has(accessory.uuid)) {
543
- throw new MatterDeviceError(`Accessory with UUID ${accessory.uuid} is already registered`);
748
+ const existing = this.accessories.get(accessory.uuid);
749
+ throw new MatterDeviceError(`Matter accessory with UUID "${accessory.uuid}" is already registered.\n`
750
+ + `Existing accessory: "${existing?.displayName}"\n`
751
+ + `New accessory: "${accessory.displayName}"\n`
752
+ + 'Each accessory must have a unique UUID. Use api.hap.uuid.generate() with a unique string.');
753
+ }
754
+ // Check if there's a cached version - merge cached cluster states with new registration
755
+ if (this.accessoryCache && this.accessoryCache.hasCached(accessory.uuid)) {
756
+ const cached = this.accessoryCache.getCached(accessory.uuid);
757
+ if (cached && cached.clusters) {
758
+ // Merge cached cluster states with new ones (prefer cached state to persist values across restarts)
759
+ for (const [clusterName, cachedAttrs] of Object.entries(cached.clusters)) {
760
+ if (!accessory.clusters[clusterName]) {
761
+ // Cluster exists in cache but not in new registration - preserve it
762
+ accessory.clusters[clusterName] = cachedAttrs;
763
+ }
764
+ else {
765
+ // Cluster exists in both - merge (prefer cached state over initial values)
766
+ accessory.clusters[clusterName] = {
767
+ ...accessory.clusters[clusterName],
768
+ ...cachedAttrs,
769
+ };
770
+ }
771
+ }
772
+ // Restore context if available
773
+ if (cached.context && !accessory.context) {
774
+ accessory.context = cached.context;
775
+ }
776
+ log.info(`Restored cached state for Matter accessory: ${accessory.displayName}`);
777
+ }
544
778
  }
545
779
  // Check device limit
546
780
  if (this.accessories.size >= this.MAX_DEVICES) {
547
- throw new MatterDeviceError(`Maximum device limit (${this.MAX_DEVICES}) reached`);
781
+ throw new MatterDeviceError(`Cannot register Matter accessory "${accessory.displayName}": `
782
+ + `Maximum device limit reached (${this.MAX_DEVICES} devices).\n`
783
+ + `Current registered devices: ${this.accessories.size}`);
548
784
  }
549
785
  try {
550
- // Create endpoint with device type
551
- const endpoint = new Endpoint(accessory.deviceType, {
786
+ // Modify device type with custom behaviors if handlers are defined
787
+ let deviceType = accessory.deviceType;
788
+ if (accessory.handlers) {
789
+ // Map cluster names to custom behavior classes
790
+ // Only clusters with user-triggered commands need custom behaviors
791
+ const behaviorMap = {
792
+ // Core controls
793
+ onOff: HomebridgeOnOffServer,
794
+ levelControl: HomebridgeLevelControlServer,
795
+ colorControl: HomebridgeColorControlServer,
796
+ // Coverings & locks
797
+ windowCovering: HomebridgeWindowCoveringServer,
798
+ doorLock: HomebridgeDoorLockServer,
799
+ // Climate control
800
+ thermostat: HomebridgeThermostatServer,
801
+ // Identification
802
+ identify: HomebridgeIdentifyServer,
803
+ };
804
+ // Build array of custom behaviors to apply based on what handlers are defined
805
+ const customBehaviors = [];
806
+ for (const clusterName of Object.keys(accessory.handlers)) {
807
+ const behaviorClass = behaviorMap[clusterName];
808
+ if (behaviorClass) {
809
+ customBehaviors.push(behaviorClass);
810
+ log.info(`Will use ${behaviorClass.name} for ${accessory.displayName}`);
811
+ }
812
+ else {
813
+ log.warn(`No custom behavior class available for cluster '${clusterName}' - handlers will be registered but may not be called`);
814
+ }
815
+ }
816
+ if (customBehaviors.length > 0) {
817
+ // Cast to any to bypass TypeScript limitations
818
+ deviceType = deviceType.with(...customBehaviors);
819
+ log.info(`Applied ${customBehaviors.length} custom behavior(s) to device type`);
820
+ }
821
+ }
822
+ // Create endpoint with the modified device type
823
+ const endpoint = new Endpoint(deviceType, {
552
824
  id: accessory.uuid,
553
825
  });
554
826
  // Add to aggregator FIRST (required before we can configure it)
555
827
  await this.aggregator.add(endpoint);
828
+ if (this.config.debugModeEnabled) {
829
+ log.debug(`Added endpoint for ${accessory.displayName} to aggregator`);
830
+ }
556
831
  // NOW configure the endpoint
557
832
  await this.configureEndpoint(endpoint, accessory);
558
833
  // Store accessory
@@ -563,6 +838,17 @@ export class MatterServer {
563
838
  };
564
839
  this.accessories.set(accessory.uuid, internalAccessory);
565
840
  log.info(`Registered Matter accessory: ${accessory.displayName} (${accessory.uuid})`);
841
+ if (this.config.debugModeEnabled) {
842
+ log.debug(`Total registered accessories: ${this.accessories.size}/${this.MAX_DEVICES}`);
843
+ }
844
+ // Emit accessory-registered event
845
+ this.emit('accessory-registered', accessory);
846
+ // Save to cache asynchronously (don't block registration)
847
+ if (this.accessoryCache) {
848
+ this.accessoryCache.save(this.accessories).catch((error) => {
849
+ log.warn('Failed to save accessory cache:', error);
850
+ });
851
+ }
566
852
  }
567
853
  catch (error) {
568
854
  log.error(`Failed to register Matter accessory ${accessory.displayName}:`, error);
@@ -585,6 +871,15 @@ export class MatterServer {
585
871
  }
586
872
  this.accessories.delete(uuid);
587
873
  log.info(`Unregistered Matter accessory: ${accessory.displayName} (${uuid})`);
874
+ // Emit accessory-unregistered event
875
+ this.emit('accessory-unregistered', uuid);
876
+ // Update cache (remove the accessory)
877
+ if (this.accessoryCache) {
878
+ this.accessoryCache.removeCached(uuid);
879
+ this.accessoryCache.save(this.accessories).catch((error) => {
880
+ log.warn('Failed to save accessory cache:', error);
881
+ });
882
+ }
588
883
  }
589
884
  catch (error) {
590
885
  log.error(`Failed to unregister Matter accessory ${uuid}:`, error);
@@ -593,27 +888,113 @@ export class MatterServer {
593
888
  }
594
889
  /**
595
890
  * Update a Matter accessory's state (Plugin API)
891
+ *
892
+ * This method can be called from anywhere, including from within handlers.
893
+ * State updates are automatically deferred to avoid transaction conflicts.
596
894
  */
597
895
  async updateAccessoryState(uuid, cluster, attributes) {
598
896
  const accessory = this.accessories.get(uuid);
599
897
  if (!accessory || !accessory.endpoint) {
600
898
  throw new MatterDeviceError(`Accessory ${uuid} not found or not registered`);
601
899
  }
900
+ // Defer the update to avoid "read-only transaction" errors when called from handlers
901
+ // Matter.js uses transactions, and we need to wait until the transaction fully completes
902
+ // Use setTimeout with a delay to ensure we're completely outside the transaction
903
+ return new Promise((resolve, reject) => {
904
+ setTimeout(async () => {
905
+ try {
906
+ // Use endpoint.set() method which is the proper way to update state
907
+ // This handles transactions correctly
908
+ const endpoint = accessory.endpoint;
909
+ // Construct the update object
910
+ const updateObject = { [cluster]: attributes };
911
+ // Use endpoint.set() which properly handles state updates
912
+ await endpoint.set(updateObject);
913
+ // CRITICAL: Also update the cached clusters object so state persists across restarts
914
+ // Merge the new attributes into the existing cluster state
915
+ if (!accessory.clusters[cluster]) {
916
+ accessory.clusters[cluster] = {};
917
+ }
918
+ accessory.clusters[cluster] = {
919
+ ...accessory.clusters[cluster],
920
+ ...attributes,
921
+ };
922
+ log.debug(`Updated ${cluster} state for ${accessory.displayName}:`, attributes);
923
+ resolve();
924
+ }
925
+ catch (error) {
926
+ log.error(`Failed to update state for accessory ${uuid}:`, error);
927
+ reject(new MatterDeviceError(`Failed to update accessory state: ${error}`));
928
+ }
929
+ }, 50); // 50ms delay to ensure we're completely outside the transaction
930
+ });
931
+ }
932
+ /**
933
+ * Get a Matter accessory's current state (Plugin API)
934
+ *
935
+ * Returns the current cluster attribute values that are exposed to Matter controllers.
936
+ * This is useful for:
937
+ * - Reading state after plugin restart (when local variables are lost)
938
+ * - Verifying current state before making changes
939
+ * - Multiple parts of code that need to read state
940
+ * - Debugging and logging
941
+ *
942
+ * @param uuid - The UUID of the accessory
943
+ * @param cluster - The cluster name (e.g., 'onOff', 'levelControl')
944
+ * @returns Current cluster attribute values, or undefined if cluster not found
945
+ */
946
+ getAccessoryState(uuid, cluster) {
947
+ const accessory = this.accessories.get(uuid);
948
+ if (!accessory || !accessory.endpoint) {
949
+ log.debug(`Accessory ${uuid} not found or not registered`);
950
+ return undefined;
951
+ }
602
952
  try {
603
- // Update the endpoint's cluster state
604
- // Note: Endpoint types from Matter.js don't expose state properly, needs runtime check
605
953
  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);
954
+ if (!endpoint.state) {
955
+ log.debug(`endpoint.state is undefined for ${accessory.displayName}`);
956
+ return undefined;
609
957
  }
610
- else {
611
- log.warn(`Cluster ${cluster} not found on accessory ${accessory.displayName}`);
958
+ if (!endpoint.state[cluster]) {
959
+ const availableClusters = Object.keys(endpoint.state || {});
960
+ log.debug(`Cluster '${cluster}' not found on ${accessory.displayName}. Available: ${availableClusters.join(', ')}`);
961
+ return undefined;
612
962
  }
963
+ const clusterState = endpoint.state[cluster];
964
+ // Build result object by reading each property directly
965
+ const result = {};
966
+ // Get list of properties to read - use both approaches for maximum compatibility
967
+ const allKeys = new Set([
968
+ ...Object.keys(clusterState),
969
+ ...Object.getOwnPropertyNames(clusterState),
970
+ ]);
971
+ for (const key of allKeys) {
972
+ try {
973
+ // Skip internal properties, methods, and symbols
974
+ if (key.startsWith('_') || key.startsWith('$')) {
975
+ continue;
976
+ }
977
+ // Try to read the value directly
978
+ const value = clusterState[key];
979
+ // Skip functions and undefined values
980
+ if (typeof value === 'function' || value === undefined) {
981
+ continue;
982
+ }
983
+ result[key] = value;
984
+ }
985
+ catch (propError) {
986
+ log.debug(`Could not read property ${key} from ${cluster}:`, propError);
987
+ }
988
+ }
989
+ if (Object.keys(result).length === 0) {
990
+ log.debug(`Cluster ${cluster} found but no readable properties on accessory ${accessory.displayName}`);
991
+ return undefined;
992
+ }
993
+ return result;
613
994
  }
614
995
  catch (error) {
615
- log.error(`Failed to update state for accessory ${uuid}:`, error);
616
- throw new MatterDeviceError(`Failed to update accessory state: ${error}`);
996
+ log.error(`Failed to get state for accessory ${uuid}:`, error);
997
+ return undefined;
617
998
  }
618
999
  }
619
1000
  /**
@@ -673,54 +1054,13 @@ export class MatterServer {
673
1054
  // Set up command handlers if provided
674
1055
  if (accessory.handlers) {
675
1056
  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
- }
1057
+ // Register handlers with the custom behavior classes
1058
+ for (const [clusterName, handlers] of Object.entries(accessory.handlers)) {
1059
+ log.info(` Processing cluster: ${clusterName}`);
1060
+ for (const [commandName, handler] of Object.entries(handlers)) {
1061
+ registerHandler(accessory.uuid, clusterName, commandName, handler);
722
1062
  }
723
- });
1063
+ }
724
1064
  }
725
1065
  }
726
1066
  /**
@@ -733,9 +1073,14 @@ export class MatterServer {
733
1073
  }
734
1074
  this.isRunning = false;
735
1075
  // Stop monitoring
1076
+ this.stopFabricMonitoring();
736
1077
  networkMonitor.stopMonitoring();
737
- diagnostics.stopDiagnostics();
738
1078
  try {
1079
+ // Save accessory cache before shutting down (BEFORE clearing accessories!)
1080
+ if (this.accessoryCache && this.accessories.size > 0) {
1081
+ await this.accessoryCache.save(this.accessories);
1082
+ log.debug('Saved accessory cache before shutdown');
1083
+ }
739
1084
  // Clean up all accessories
740
1085
  for (const accessory of this.accessories.values()) {
741
1086
  try {
@@ -882,5 +1227,40 @@ export class MatterServer {
882
1227
  getClusters() {
883
1228
  return clusters;
884
1229
  }
1230
+ /**
1231
+ * Remove a specific fabric (controller) from the bridge
1232
+ * This decommissions a single controller while leaving others intact
1233
+ *
1234
+ * @param fabricIndex - The fabric index to remove
1235
+ * @returns Promise that resolves when the fabric is removed
1236
+ */
1237
+ async removeFabric(fabricIndex) {
1238
+ if (!this.serverNode) {
1239
+ throw new MatterDeviceError('Matter server not started');
1240
+ }
1241
+ try {
1242
+ log.info(`Removing fabric ${fabricIndex}...`);
1243
+ const serverState = this.serverNode;
1244
+ const removeFabric = serverState?.state?.commissioning?.removeFabric;
1245
+ if (typeof removeFabric !== 'function') {
1246
+ throw new MatterDeviceError('Fabric removal not supported by Matter.js version');
1247
+ }
1248
+ // Remove the fabric
1249
+ await removeFabric(fabricIndex);
1250
+ log.info(`Fabric ${fabricIndex} removed successfully`);
1251
+ // The fabric monitoring will detect this change and emit the appropriate events
1252
+ }
1253
+ catch (error) {
1254
+ log.error(`Failed to remove fabric ${fabricIndex}:`, error);
1255
+ throw new MatterDeviceError(`Failed to remove fabric: ${error.message}`, error);
1256
+ }
1257
+ }
1258
+ /**
1259
+ * Check if a specific fabric exists
1260
+ */
1261
+ hasFabric(fabricIndex) {
1262
+ const fabrics = this.getFabricInfo();
1263
+ return fabrics.some(f => f.fabricIndex === fabricIndex);
1264
+ }
885
1265
  }
886
1266
  //# sourceMappingURL=matterServer.js.map