homebridge 2.0.0-alpha.5 → 2.0.0-alpha.51

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 (105) hide show
  1. package/README.md +1 -1
  2. package/bin/homebridge.js +22 -0
  3. package/dist/api.d.ts +219 -3
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +79 -0
  6. package/dist/api.js.map +1 -1
  7. package/dist/bridgeService.d.ts +10 -3
  8. package/dist/bridgeService.d.ts.map +1 -1
  9. package/dist/bridgeService.js +9 -5
  10. package/dist/bridgeService.js.map +1 -1
  11. package/dist/childBridgeFork.d.ts +17 -2
  12. package/dist/childBridgeFork.d.ts.map +1 -1
  13. package/dist/childBridgeFork.js +161 -4
  14. package/dist/childBridgeFork.js.map +1 -1
  15. package/dist/childBridgeService.d.ts +21 -0
  16. package/dist/childBridgeService.d.ts.map +1 -1
  17. package/dist/childBridgeService.js +72 -25
  18. package/dist/childBridgeService.js.map +1 -1
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +3 -2
  21. package/dist/cli.js.map +1 -1
  22. package/dist/externalPortService.js.map +1 -1
  23. package/dist/index.d.ts +48 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +15 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/ipcService.d.ts +20 -0
  28. package/dist/ipcService.d.ts.map +1 -1
  29. package/dist/ipcService.js.map +1 -1
  30. package/dist/logger.d.ts +6 -0
  31. package/dist/logger.d.ts.map +1 -1
  32. package/dist/logger.js +8 -0
  33. package/dist/logger.js.map +1 -1
  34. package/dist/matter/index.d.ts +51 -0
  35. package/dist/matter/index.d.ts.map +1 -0
  36. package/dist/matter/index.js +11 -0
  37. package/dist/matter/index.js.map +1 -0
  38. package/dist/matter/matterAccessoryCache.d.ts +77 -0
  39. package/dist/matter/matterAccessoryCache.d.ts.map +1 -0
  40. package/dist/matter/matterAccessoryCache.js +176 -0
  41. package/dist/matter/matterAccessoryCache.js.map +1 -0
  42. package/dist/matter/matterBehaviors.d.ts +127 -0
  43. package/dist/matter/matterBehaviors.d.ts.map +1 -0
  44. package/dist/matter/matterBehaviors.js +611 -0
  45. package/dist/matter/matterBehaviors.js.map +1 -0
  46. package/dist/matter/matterConfigValidator.d.ts +81 -0
  47. package/dist/matter/matterConfigValidator.d.ts.map +1 -0
  48. package/dist/matter/matterConfigValidator.js +240 -0
  49. package/dist/matter/matterConfigValidator.js.map +1 -0
  50. package/dist/matter/matterErrorHandler.d.ts +94 -0
  51. package/dist/matter/matterErrorHandler.d.ts.map +1 -0
  52. package/dist/matter/matterErrorHandler.js +485 -0
  53. package/dist/matter/matterErrorHandler.js.map +1 -0
  54. package/dist/matter/matterLogFormatter.d.ts +18 -0
  55. package/dist/matter/matterLogFormatter.d.ts.map +1 -0
  56. package/dist/matter/matterLogFormatter.js +128 -0
  57. package/dist/matter/matterLogFormatter.js.map +1 -0
  58. package/dist/matter/matterNetworkMonitor.d.ts +68 -0
  59. package/dist/matter/matterNetworkMonitor.d.ts.map +1 -0
  60. package/dist/matter/matterNetworkMonitor.js +250 -0
  61. package/dist/matter/matterNetworkMonitor.js.map +1 -0
  62. package/dist/matter/matterServer.d.ts +642 -0
  63. package/dist/matter/matterServer.d.ts.map +1 -0
  64. package/dist/matter/matterServer.js +1535 -0
  65. package/dist/matter/matterServer.js.map +1 -0
  66. package/dist/matter/matterSharedTypes.d.ts +165 -0
  67. package/dist/matter/matterSharedTypes.d.ts.map +1 -0
  68. package/dist/matter/matterSharedTypes.js +51 -0
  69. package/dist/matter/matterSharedTypes.js.map +1 -0
  70. package/dist/matter/matterStorage.d.ts +126 -0
  71. package/dist/matter/matterStorage.d.ts.map +1 -0
  72. package/dist/matter/matterStorage.js +419 -0
  73. package/dist/matter/matterStorage.js.map +1 -0
  74. package/dist/matter/matterTypes.d.ts +612 -0
  75. package/dist/matter/matterTypes.d.ts.map +1 -0
  76. package/dist/matter/matterTypes.js +148 -0
  77. package/dist/matter/matterTypes.js.map +1 -0
  78. package/dist/platformAccessory.d.ts +1 -0
  79. package/dist/platformAccessory.d.ts.map +1 -1
  80. package/dist/platformAccessory.js +8 -1
  81. package/dist/platformAccessory.js.map +1 -1
  82. package/dist/plugin.d.ts +0 -1
  83. package/dist/plugin.d.ts.map +1 -1
  84. package/dist/plugin.js +9 -12
  85. package/dist/plugin.js.map +1 -1
  86. package/dist/pluginManager.d.ts.map +1 -1
  87. package/dist/pluginManager.js +22 -21
  88. package/dist/pluginManager.js.map +1 -1
  89. package/dist/server.d.ts +16 -1
  90. package/dist/server.d.ts.map +1 -1
  91. package/dist/server.js +276 -8
  92. package/dist/server.js.map +1 -1
  93. package/dist/storageService.js +8 -8
  94. package/dist/storageService.js.map +1 -1
  95. package/dist/user.d.ts +1 -0
  96. package/dist/user.d.ts.map +1 -1
  97. package/dist/user.js +10 -7
  98. package/dist/user.js.map +1 -1
  99. package/dist/util/mac.d.ts.map +1 -1
  100. package/dist/util/mac.js +2 -2
  101. package/dist/util/mac.js.map +1 -1
  102. package/dist/version.js +2 -2
  103. package/dist/version.js.map +1 -1
  104. package/package.json +25 -26
  105. package/bin/homebridge +0 -19
@@ -0,0 +1,1535 @@
1
+ /**
2
+ * Matter.js Server Implementation for Homebridge Plugin API
3
+ *
4
+ * This provides a Matter bridge that plugins can use to register
5
+ * Matter accessories via the Homebridge API.
6
+ */
7
+ import * as crypto from 'node:crypto';
8
+ import { EventEmitter } from 'node:events';
9
+ import * as fs from 'node:fs';
10
+ import { access, writeFile } from 'node:fs/promises';
11
+ import * as os from 'node:os';
12
+ import * as path from 'node:path';
13
+ import process from 'node:process';
14
+ import { Endpoint, Environment, Logger as MatterLogger, LogLevel as MatterLogLevel, ServerNode, StorageService, VendorId, } from '@matter/main';
15
+ import { WindowCoveringServer } from '@matter/main/behaviors';
16
+ import { AggregatorEndpoint } from '@matter/main/endpoints';
17
+ import { ManualPairingCodeCodec, QrPairingCodeCodec } from '@matter/types/schema';
18
+ import * as fse from 'fs-extra';
19
+ import QRCode from 'qrcode-terminal';
20
+ import { Logger } from '../logger.js';
21
+ import getVersion from '../version.js';
22
+ import { MatterAccessoryCache } from './matterAccessoryCache.js';
23
+ import { HomebridgeColorControlServer, HomebridgeDoorLockServer, HomebridgeIdentifyServer, HomebridgeLevelControlServer, HomebridgeOnOffServer, HomebridgeThermostatServer, HomebridgeWindowCoveringServer, registerHandler, setAccessoriesMap, } from './matterBehaviors.js';
24
+ import { sanitizeUniqueId, truncateString, validatePort } from './matterConfigValidator.js';
25
+ import { errorHandler } from './matterErrorHandler.js';
26
+ import { createHomebridgeLogFormatter } from './matterLogFormatter.js';
27
+ import { networkMonitor } from './matterNetworkMonitor.js';
28
+ import { MatterStorageManager } from './matterStorage.js';
29
+ import { clusters, deviceTypes, MatterDeviceError, } from './matterTypes.js';
30
+ const log = Logger.withPrefix('Matter/Server');
31
+ // Constants for Matter server configuration
32
+ const DEFAULT_MATTER_PORT = 5540;
33
+ const DEFAULT_VENDOR_ID = 0xFFF1; // Test vendor ID from Matter spec
34
+ const DEFAULT_PRODUCT_ID = 0x8001; // Test product ID
35
+ const MAX_DEVICES_PER_BRIDGE = 1000; // Matter spec maximum devices per aggregator
36
+ const SERVER_READY_TIMEOUT_MS = 5000;
37
+ const SERVER_READY_POLL_INTERVAL_MS = 100;
38
+ const SERVER_INIT_DELAY_MS = 200;
39
+ const MAX_PASSCODE_ATTEMPTS = 100;
40
+ const FABRIC_MONITOR_INTERVAL_MS = 2000; // Check for fabric changes every 2 seconds
41
+ /**
42
+ * Matter Server for Homebridge Plugin API
43
+ * Allows plugins to register Matter accessories explicitly
44
+ *
45
+ * Extends EventEmitter to provide commissioning lifecycle events
46
+ */
47
+ export class MatterServer extends EventEmitter {
48
+ config;
49
+ serverNode = null;
50
+ aggregator = null;
51
+ accessories = new Map();
52
+ isRunning = false;
53
+ MAX_DEVICES = MAX_DEVICES_PER_BRIDGE;
54
+ shutdownHandler = null;
55
+ // Internal commissioning values (generated, not user-configurable)
56
+ passcode = 0;
57
+ discriminator = 0;
58
+ vendorId;
59
+ productId;
60
+ commissioningInfo = {};
61
+ serialNumber;
62
+ cleanupHandlers = [];
63
+ storageManager = null;
64
+ matterStoragePath;
65
+ accessoryCache = null;
66
+ // Fabric monitoring
67
+ fabricMonitorInterval = null;
68
+ previousFabrics = new Map();
69
+ constructor(config) {
70
+ super();
71
+ // Store the validated config
72
+ this.config = this.validateAndSanitizeConfig(config);
73
+ // Configure Matter.js library logging
74
+ // Suppress DEBUG/INFO logs from Matter.js library unless debug mode is explicitly enabled
75
+ if (this.config.debugModeEnabled) {
76
+ log.info('Matter debug mode enabled - verbose logging active');
77
+ MatterLogger.level = MatterLogLevel.DEBUG;
78
+ }
79
+ else {
80
+ MatterLogger.level = MatterLogLevel.NOTICE;
81
+ }
82
+ // Set custom log format to match homebridge format:
83
+ // [DD/MM/YYYY, HH:MM:SS] [Matter/Facility] message
84
+ MatterLogger.format = createHomebridgeLogFormatter();
85
+ // Initialize commissioning values (will be loaded from storage in start())
86
+ this.vendorId = DEFAULT_VENDOR_ID;
87
+ this.productId = DEFAULT_PRODUCT_ID;
88
+ // Provide accessories map reference to behaviors for cache syncing
89
+ setAccessoriesMap(this.accessories);
90
+ }
91
+ /**
92
+ * Validate and sanitize Matter server configuration
93
+ * Throws descriptive errors if configuration is invalid
94
+ */
95
+ validateAndSanitizeConfig(config) {
96
+ const errors = [];
97
+ // Validate port
98
+ const port = config.port || DEFAULT_MATTER_PORT;
99
+ const portValidation = validatePort(port, false);
100
+ if (!portValidation.valid) {
101
+ errors.push(`Invalid port: ${portValidation.error}`);
102
+ }
103
+ // Validate and sanitize uniqueId (REQUIRED)
104
+ if (!config.uniqueId) {
105
+ errors.push('uniqueId is required for Matter server configuration');
106
+ }
107
+ const rawUniqueId = config.uniqueId || '';
108
+ const uniqueIdResult = sanitizeUniqueId(rawUniqueId);
109
+ const uniqueId = uniqueIdResult.value;
110
+ if (uniqueId.length === 0) {
111
+ errors.push('Invalid uniqueId: must be a non-empty string');
112
+ }
113
+ // Validate storagePath (if provided)
114
+ let storagePath = config.storagePath;
115
+ if (storagePath !== undefined) {
116
+ storagePath = path.resolve(storagePath); // Resolve to absolute path
117
+ }
118
+ // Validate and sanitize manufacturer
119
+ let manufacturer = config.manufacturer;
120
+ if (manufacturer !== undefined) {
121
+ manufacturer = truncateString(manufacturer, 32, 'Manufacturer name').value;
122
+ }
123
+ // Validate and sanitize model
124
+ let model = config.model;
125
+ if (model !== undefined) {
126
+ model = truncateString(model, 32, 'Model name').value;
127
+ }
128
+ // Validate firmwareRevision
129
+ let firmwareRevision = config.firmwareRevision;
130
+ if (firmwareRevision !== undefined) {
131
+ firmwareRevision = truncateString(firmwareRevision, 64, 'Firmware revision').value;
132
+ }
133
+ // Validate serialNumber
134
+ let serialNumber = config.serialNumber;
135
+ if (serialNumber !== undefined) {
136
+ serialNumber = truncateString(serialNumber, 32, 'Serial number').value;
137
+ }
138
+ // Validate debugModeEnabled
139
+ const debugModeEnabled = config.debugModeEnabled || false;
140
+ // Throw if there are validation errors
141
+ if (errors.length > 0) {
142
+ throw new MatterDeviceError(`Matter configuration validation failed:\n${errors.map(e => ` - ${e}`).join('\n')}`);
143
+ }
144
+ return {
145
+ port,
146
+ uniqueId,
147
+ storagePath,
148
+ manufacturer,
149
+ model,
150
+ firmwareRevision,
151
+ serialNumber,
152
+ debugModeEnabled,
153
+ };
154
+ }
155
+ /**
156
+ * Generate a secure random passcode
157
+ * According to Matter spec, passcode must be:
158
+ * - 8 digits (00000001 to 99999998)
159
+ * - Not in the invalid list
160
+ * - Not sequential or repeating patterns
161
+ */
162
+ generateSecurePasscode() {
163
+ let passcode;
164
+ const maxAttempts = MAX_PASSCODE_ATTEMPTS;
165
+ let attempts = 0;
166
+ const invalidPasscodes = [
167
+ 0,
168
+ 11111111,
169
+ 22222222,
170
+ 33333333,
171
+ 44444444,
172
+ 55555555,
173
+ 66666666,
174
+ 77777777,
175
+ 88888888,
176
+ 99999999,
177
+ 12345678,
178
+ 87654321,
179
+ ];
180
+ do {
181
+ // Use cryptographically secure random number generation
182
+ const randomBytes = crypto.randomBytes(4);
183
+ const randomValue = randomBytes.readUInt32BE(0);
184
+ // Generate a value between 1 and 99999998
185
+ passcode = (randomValue % 99999998) + 1;
186
+ attempts++;
187
+ if (attempts > maxAttempts) {
188
+ throw new Error('Failed to generate secure passcode after maximum attempts');
189
+ }
190
+ } while (invalidPasscodes.includes(passcode)
191
+ || !this.isValidPasscode(passcode));
192
+ return passcode;
193
+ }
194
+ /**
195
+ * Validate a passcode according to Matter specifications
196
+ */
197
+ isValidPasscode(passcode) {
198
+ // Must be between 1 and 99999998
199
+ if (passcode < 1 || passcode > 99999998) {
200
+ return false;
201
+ }
202
+ // Convert to 8-digit string
203
+ const passcodeStr = passcode.toString().padStart(8, '0');
204
+ // Check for sequential patterns (12345678, 23456789, etc.)
205
+ let isSequential = true;
206
+ for (let i = 1; i < passcodeStr.length; i++) {
207
+ if (Number.parseInt(passcodeStr[i]) !== Number.parseInt(passcodeStr[i - 1]) + 1) {
208
+ isSequential = false;
209
+ break;
210
+ }
211
+ }
212
+ if (isSequential) {
213
+ return false;
214
+ }
215
+ // Check for reverse sequential (87654321, 76543210, etc.)
216
+ let isReverseSequential = true;
217
+ for (let i = 1; i < passcodeStr.length; i++) {
218
+ if (Number.parseInt(passcodeStr[i]) !== Number.parseInt(passcodeStr[i - 1]) - 1) {
219
+ isReverseSequential = false;
220
+ break;
221
+ }
222
+ }
223
+ if (isReverseSequential) {
224
+ return false;
225
+ }
226
+ // Check for too many repeating digits (more than 3 of same digit)
227
+ const digitCounts = new Map();
228
+ for (const digit of passcodeStr) {
229
+ digitCounts.set(digit, (digitCounts.get(digit) || 0) + 1);
230
+ if (digitCounts.get(digit) > 3) {
231
+ return false;
232
+ }
233
+ }
234
+ return true;
235
+ }
236
+ /**
237
+ * Generate a random discriminator
238
+ * According to Matter spec, discriminator must be:
239
+ * - 12 bits (0-4095)
240
+ * - Should be random for security
241
+ */
242
+ generateRandomDiscriminator() {
243
+ // Generate cryptographically secure random 12-bit discriminator (0-4095)
244
+ const randomBytes = crypto.randomBytes(2);
245
+ const discriminator = randomBytes.readUInt16BE(0) & 0x0FFF; // Mask to 12 bits
246
+ // Validate discriminator range
247
+ if (discriminator < 0 || discriminator > 4095) {
248
+ throw new Error(`Invalid discriminator generated: ${discriminator}`);
249
+ }
250
+ return discriminator;
251
+ }
252
+ /**
253
+ * Create ServerNode with automatic recovery from corrupted storage
254
+ *
255
+ * Matter.js can fail to start if fabric data is corrupted (common after
256
+ * hard shutdowns or disk errors). This method implements automatic recovery by:
257
+ *
258
+ * 1. Attempting normal ServerNode creation
259
+ * 2. If it fails with storage errors, identifying and removing corrupted files
260
+ * 3. Retrying ServerNode creation with fresh storage
261
+ *
262
+ * This prevents the need for manual intervention while preserving data
263
+ * safety by only removing storage on confirmed corruption errors.
264
+ *
265
+ * @param nodeOptions - Matter.js ServerNode configuration
266
+ * @param sanitizedId - Filesystem-safe bridge identifier
267
+ * @returns Initialized ServerNode instance
268
+ * @throws Error if recovery fails or error is not storage-related
269
+ */
270
+ async createServerNodeWithRecovery(nodeOptions, sanitizedId) {
271
+ try {
272
+ // First attempt to create ServerNode
273
+ return await ServerNode.create(nodeOptions);
274
+ }
275
+ catch (error) {
276
+ // Check if this is a storage corruption error
277
+ const isStorageError = error?.message?.includes('Invalid public key encoding')
278
+ || error?.message?.includes('FabricManager unavailable')
279
+ || error?.message?.includes('key-input')
280
+ || error?.cause?.message?.includes('Invalid public key encoding');
281
+ if (!isStorageError) {
282
+ // Not a storage error, rethrow
283
+ throw error;
284
+ }
285
+ // Storage is corrupted - clean up and retry
286
+ log.warn('Detected corrupted Matter storage, attempting automatic recovery...');
287
+ // The ServerNodeStore directory is inside our storage path with the same name as the bridge ID
288
+ const environment = Environment.default;
289
+ const storageService = environment.get(StorageService);
290
+ const storageLocation = storageService.location;
291
+ if (!storageLocation) {
292
+ throw new Error('Storage location not set, cannot recover from corrupted storage');
293
+ }
294
+ const serverNodeStorePath = path.join(storageLocation, sanitizedId);
295
+ const serverNodeStoreJsonFile = `${serverNodeStorePath}.json`;
296
+ try {
297
+ let removedSomething = false;
298
+ // Delete the ServerNodeStore subdirectory
299
+ if (fs.existsSync(serverNodeStorePath)) {
300
+ log.info(`Removing corrupted ServerNodeStore directory: ${serverNodeStorePath}`);
301
+ await fse.remove(serverNodeStorePath);
302
+ removedSomething = true;
303
+ }
304
+ // Delete the ServerNodeStore JSON file (contains fabric data)
305
+ if (fs.existsSync(serverNodeStoreJsonFile)) {
306
+ log.info(`Removing corrupted ServerNodeStore JSON file: ${serverNodeStoreJsonFile}`);
307
+ await fse.remove(serverNodeStoreJsonFile);
308
+ removedSomething = true;
309
+ }
310
+ if (removedSomething) {
311
+ log.info('Corrupted storage removed, retrying ServerNode creation...');
312
+ }
313
+ else {
314
+ log.warn('No corrupted storage files found, corruption may be elsewhere');
315
+ }
316
+ // Retry ServerNode creation
317
+ const serverNode = await ServerNode.create(nodeOptions);
318
+ log.info('Successfully recovered from corrupted Matter storage');
319
+ return serverNode;
320
+ }
321
+ catch (retryError) {
322
+ log.error('Failed to recover from corrupted storage:', retryError);
323
+ log.error('Original error:', error);
324
+ throw new Error('Matter storage is corrupted and automatic recovery failed. '
325
+ + `Please manually delete: ${serverNodeStorePath}`);
326
+ }
327
+ }
328
+ }
329
+ /**
330
+ * Start the Matter server
331
+ */
332
+ async start() {
333
+ if (this.isRunning) {
334
+ log.warn('Matter server is already running');
335
+ return;
336
+ }
337
+ try {
338
+ log.info('Starting Matter.js server...');
339
+ // IMPORTANT: Storage must be configured BEFORE any Matter.js operations
340
+ // This ensures persistent fabric data across restarts
341
+ await this.setupStorage();
342
+ // Load or generate commissioning credentials
343
+ await this.loadOrGenerateCredentials();
344
+ log.info(`Configuration: Port=${this.config.port}, Passcode=${this.passcode}, Discriminator=${this.discriminator}`);
345
+ // Start network monitoring
346
+ networkMonitor.startMonitoring();
347
+ this.cleanupHandlers.push(() => networkMonitor.stopMonitoring());
348
+ // Create commissioning options
349
+ const commissioningOptions = {
350
+ passcode: this.passcode,
351
+ discriminator: this.discriminator,
352
+ };
353
+ log.info(`Using commissioning credentials: passcode=${this.passcode}, discriminator=${this.discriminator}`);
354
+ // Use default name for the Matter bridge
355
+ const bridgeName = 'Homebridge Matter Bridge';
356
+ // uniqueId is already sanitized in validateAndSanitizeConfig()
357
+ const sanitizedId = this.config.uniqueId;
358
+ // Create node options with proper typing
359
+ const nodeOptions = {
360
+ id: sanitizedId,
361
+ network: {
362
+ port: this.config.port,
363
+ ipv4: true, // Always enable IPv4 for Matter
364
+ },
365
+ commissioning: commissioningOptions,
366
+ productDescription: {
367
+ name: bridgeName,
368
+ deviceType: AggregatorEndpoint.deviceType,
369
+ },
370
+ basicInformation: {
371
+ nodeLabel: bridgeName.slice(0, 32), // Maximum 32 characters
372
+ vendorId: VendorId(this.vendorId),
373
+ vendorName: (this.config.manufacturer || 'Homebridge').slice(0, 32),
374
+ productId: this.productId,
375
+ productName: (this.config.model || 'Homebridge Matter Bridge').slice(0, 32),
376
+ productLabel: bridgeName.slice(0, 64), // Maximum 64 characters
377
+ serialNumber: this.serialNumber = this.config.serialNumber || this.config.uniqueId,
378
+ hardwareVersion: 1,
379
+ hardwareVersionString: os.release(),
380
+ softwareVersion: 1,
381
+ softwareVersionString: this.config.firmwareRevision || getVersion(),
382
+ reachable: true,
383
+ },
384
+ };
385
+ // Create server node with automatic recovery from corrupted storage
386
+ this.serverNode = await this.createServerNodeWithRecovery(nodeOptions, sanitizedId);
387
+ // Create aggregator endpoint for bridge pattern
388
+ this.aggregator = new Endpoint(AggregatorEndpoint, {
389
+ id: 'homebridge-aggregator',
390
+ });
391
+ // Add aggregator to server
392
+ await this.serverNode.add(this.aggregator);
393
+ // Generate and display commissioning information
394
+ await this.generateCommissioningInfo();
395
+ // Set up graceful shutdown handler
396
+ this.shutdownHandler = async () => {
397
+ log.info('Shutting down Matter server...');
398
+ await this.stop();
399
+ };
400
+ // Register shutdown handlers
401
+ process.on('SIGINT', this.shutdownHandler);
402
+ process.on('SIGTERM', this.shutdownHandler);
403
+ // Start the server in a non-blocking way
404
+ this.serverNode.run().then(() => {
405
+ log.info('Matter server stopped normally');
406
+ }, (error) => {
407
+ log.error('Matter server stopped with error:', error);
408
+ errorHandler.handleError(error, 'server-runtime');
409
+ });
410
+ // Wait for server to be ready
411
+ await this.waitForServerReady();
412
+ // Load cached accessories (don't restore them yet - wait for plugins to re-register)
413
+ if (this.accessoryCache) {
414
+ const loaded = await this.accessoryCache.load();
415
+ log.debug(`Matter cache loaded: ${loaded.size} accessories`);
416
+ }
417
+ else {
418
+ log.debug('No accessory cache available');
419
+ }
420
+ // Start fabric monitoring to emit commissioning events
421
+ this.startFabricMonitoring();
422
+ this.isRunning = true;
423
+ log.info(`Matter server started successfully on port ${this.config.port}`);
424
+ log.info('Plugins can now register Matter accessories via the API');
425
+ }
426
+ catch (error) {
427
+ log.error('Failed to start Matter server:', error);
428
+ await this.cleanup();
429
+ throw error;
430
+ }
431
+ }
432
+ /**
433
+ * Set up and validate storage
434
+ */
435
+ async setupStorage() {
436
+ if (!this.config.storagePath) {
437
+ throw new Error('Storage path is required for Matter server');
438
+ }
439
+ // Resolve to absolute path and validate
440
+ const storagePath = path.resolve(this.config.storagePath);
441
+ const normalizedPath = path.normalize(storagePath);
442
+ // Ensure path is within allowed directories
443
+ const allowedBasePaths = [
444
+ path.resolve(os.homedir(), '.homebridge'),
445
+ path.resolve(process.cwd()),
446
+ '/var/lib/homebridge', // Common system location
447
+ ];
448
+ const isAllowed = allowedBasePaths.some(basePath => normalizedPath.startsWith(basePath));
449
+ if (!isAllowed || normalizedPath.includes('..')) {
450
+ throw new Error(`Storage path not allowed: ${normalizedPath}. Must be within homebridge directories.`);
451
+ }
452
+ // Ensure the storage directory exists with proper permissions
453
+ try {
454
+ await fse.ensureDir(normalizedPath);
455
+ await access(normalizedPath, fs.constants.R_OK | fs.constants.W_OK);
456
+ }
457
+ catch (error) {
458
+ throw new Error(`Storage path not accessible: ${error}`);
459
+ }
460
+ // Create bridge-specific storage directory
461
+ // uniqueId is already sanitized in validateAndSanitizeConfig()
462
+ const bridgeId = this.config.uniqueId || 'default';
463
+ this.matterStoragePath = path.join(normalizedPath, bridgeId);
464
+ await fse.ensureDir(this.matterStoragePath);
465
+ // Create storage manager
466
+ this.storageManager = new MatterStorageManager(this.matterStoragePath);
467
+ // Create accessory cache
468
+ this.accessoryCache = new MatterAccessoryCache(normalizedPath, bridgeId);
469
+ // Configure environment to use our custom storage
470
+ const environment = Environment.default;
471
+ const storageService = environment.get(StorageService);
472
+ storageService.location = this.matterStoragePath;
473
+ // CRITICAL: Override storage factory with custom implementation
474
+ // This ensures fabric data is properly persisted
475
+ storageService.factory = (namespace) => {
476
+ if (!this.storageManager) {
477
+ throw new Error('Storage manager not initialized');
478
+ }
479
+ const storage = this.storageManager.getStorage(namespace);
480
+ // Initialize asynchronously - Matter.js handles async storage properly
481
+ storage.initialize().catch((error) => {
482
+ log.error(`Failed to initialize storage namespace ${namespace}:`, error);
483
+ });
484
+ // Note: Cast to unknown first to satisfy TypeScript - our storage implements the required interface
485
+ return storage;
486
+ };
487
+ // Add cleanup handler for storage
488
+ this.cleanupHandlers.push(async () => {
489
+ if (this.storageManager) {
490
+ await this.storageManager.closeAll();
491
+ }
492
+ });
493
+ log.info(`Matter storage initialized at: ${this.matterStoragePath}`);
494
+ }
495
+ /**
496
+ * Load or generate commissioning credentials (passcode and discriminator)
497
+ * These must be persistent across restarts to maintain the same QR code
498
+ */
499
+ async loadOrGenerateCredentials() {
500
+ if (!this.storageManager) {
501
+ throw new Error('Storage manager not initialized');
502
+ }
503
+ // Use 'credentials' namespace
504
+ const storage = this.storageManager.getStorage('credentials');
505
+ // CRITICAL: Initialize storage before reading to avoid race condition
506
+ await storage.initialize();
507
+ // Try to load existing credentials
508
+ const storedPasscode = storage.get([], 'passcode');
509
+ const storedDiscriminator = storage.get([], 'discriminator');
510
+ if (storedPasscode && storedDiscriminator) {
511
+ // Use stored credentials
512
+ log.info('Loading existing commissioning credentials from storage');
513
+ this.passcode = storedPasscode;
514
+ this.discriminator = storedDiscriminator;
515
+ }
516
+ else {
517
+ // Generate new credentials and store them
518
+ log.info('Generating new commissioning credentials');
519
+ this.passcode = this.generateSecurePasscode();
520
+ this.discriminator = this.generateRandomDiscriminator();
521
+ // Store for future use
522
+ storage.set([], 'passcode', this.passcode);
523
+ storage.set([], 'discriminator', this.discriminator);
524
+ log.info('Commissioning credentials saved to storage');
525
+ }
526
+ }
527
+ /**
528
+ * Generate and display commissioning information
529
+ */
530
+ async generateCommissioningInfo() {
531
+ const passcode = this.passcode.toString().padStart(8, '0');
532
+ const discriminator = this.discriminator;
533
+ const vendorId = this.vendorId;
534
+ const productId = this.productId;
535
+ // Use Matter.js library to generate pairing codes properly
536
+ const manualCode = ManualPairingCodeCodec.encode({
537
+ discriminator,
538
+ passcode: this.passcode,
539
+ });
540
+ // Format as XXXX-XXX-XXXX for display
541
+ const manualPairingCode = `${manualCode.slice(0, 4)}-${manualCode.slice(4, 7)}-${manualCode.slice(7, 11)}`;
542
+ log.info(`Encoding QR code with: passcode=${this.passcode}, discriminator=${discriminator}, vendorId=${vendorId}, productId=${productId}`);
543
+ const qrCodePayload = QrPairingCodeCodec.encode([{
544
+ version: 0,
545
+ vendorId,
546
+ productId,
547
+ flowType: 0, // Standard commissioning flow
548
+ discoveryCapabilities: 4, // OnNetwork=4
549
+ discriminator,
550
+ passcode: this.passcode,
551
+ }]);
552
+ log.info(`Generated QR code: ${qrCodePayload}`);
553
+ log.info(`Generated manual code: ${manualPairingCode}`);
554
+ // Store commissioning info
555
+ this.commissioningInfo = {
556
+ qrCode: qrCodePayload,
557
+ manualPairingCode,
558
+ };
559
+ // Save commissioning info to disk for UI access
560
+ try {
561
+ if (!this.matterStoragePath) {
562
+ throw new Error('Matter storage path not initialized');
563
+ }
564
+ const commissioningFilePath = path.join(this.matterStoragePath, 'commissioning.json');
565
+ const commissioningData = {
566
+ qrCode: qrCodePayload,
567
+ manualPairingCode,
568
+ serialNumber: this.serialNumber,
569
+ passcode: this.passcode,
570
+ discriminator: this.discriminator,
571
+ commissioned: this.isCommissioned(),
572
+ };
573
+ await writeFile(commissioningFilePath, JSON.stringify(commissioningData, null, 2), 'utf-8');
574
+ log.debug(`Saved commissioning info to ${commissioningFilePath}`);
575
+ }
576
+ catch (error) {
577
+ log.warn(`Failed to save commissioning info to disk: ${error.message}`);
578
+ }
579
+ // Display commissioning information
580
+ log.info(`\n${'='.repeat(60)}`);
581
+ log.info('📱 MATTER COMMISSIONING INFORMATION');
582
+ log.info('='.repeat(60));
583
+ log.info(`Manual Pairing Code: ${manualPairingCode}`);
584
+ log.info(`Passcode: ${passcode}`);
585
+ log.info(`Discriminator: ${discriminator}`);
586
+ log.info('\nQR Code for commissioning:');
587
+ // Generate and display QR code in terminal
588
+ QRCode.generate(qrCodePayload, { small: true }, (qrcode) => {
589
+ // eslint-disable-next-line no-console
590
+ console.log(qrcode);
591
+ });
592
+ log.info(`${'='.repeat(60)}\n`);
593
+ }
594
+ /**
595
+ * Wait for the server to be ready
596
+ */
597
+ async waitForServerReady(maxWaitTime = SERVER_READY_TIMEOUT_MS) {
598
+ const startTime = Date.now();
599
+ while (!this.serverNode || !this.aggregator) {
600
+ if (Date.now() - startTime > maxWaitTime) {
601
+ throw new Error('Server failed to become ready within timeout');
602
+ }
603
+ await new Promise(resolve => setTimeout(resolve, SERVER_READY_POLL_INTERVAL_MS));
604
+ }
605
+ // Additional small delay to ensure everything is initialized
606
+ await new Promise(resolve => setTimeout(resolve, SERVER_INIT_DELAY_MS));
607
+ }
608
+ /**
609
+ * Start monitoring fabric changes to emit commissioning events
610
+ */
611
+ startFabricMonitoring() {
612
+ // Stop any existing monitor
613
+ this.stopFabricMonitoring();
614
+ // Initialize with current fabrics
615
+ const initialFabrics = this.getFabricInfo();
616
+ for (const fabric of initialFabrics) {
617
+ this.previousFabrics.set(fabric.fabricIndex, fabric);
618
+ }
619
+ log.debug('Starting fabric monitoring for commissioning events');
620
+ // Set up periodic monitoring
621
+ this.fabricMonitorInterval = setInterval(() => {
622
+ this.checkFabricChanges();
623
+ }, FABRIC_MONITOR_INTERVAL_MS);
624
+ // Add to clean up handlers
625
+ this.cleanupHandlers.push(() => this.stopFabricMonitoring());
626
+ }
627
+ /**
628
+ * Stop fabric monitoring
629
+ */
630
+ stopFabricMonitoring() {
631
+ if (this.fabricMonitorInterval) {
632
+ clearInterval(this.fabricMonitorInterval);
633
+ this.fabricMonitorInterval = null;
634
+ log.debug('Stopped fabric monitoring');
635
+ }
636
+ }
637
+ /**
638
+ * Check for fabric changes and emit appropriate events
639
+ */
640
+ checkFabricChanges() {
641
+ try {
642
+ const currentFabrics = this.getFabricInfo();
643
+ const currentFabricMap = new Map();
644
+ // Build map of current fabrics
645
+ for (const fabric of currentFabrics) {
646
+ currentFabricMap.set(fabric.fabricIndex, fabric);
647
+ }
648
+ const previousCount = this.previousFabrics.size;
649
+ const currentCount = currentFabricMap.size;
650
+ // Check for added fabrics
651
+ for (const [fabricIndex, fabric] of currentFabricMap) {
652
+ if (!this.previousFabrics.has(fabricIndex)) {
653
+ log.info(`Fabric added: ${fabric.fabricId} (index: ${fabricIndex})`);
654
+ this.emit('fabric-added', fabric);
655
+ // If this is the first fabric, emit 'commissioned' event
656
+ if (previousCount === 0 && currentCount === 1) {
657
+ log.info('Bridge commissioned for the first time');
658
+ this.emit('commissioned', fabric);
659
+ }
660
+ }
661
+ }
662
+ // Check for removed fabrics
663
+ for (const [fabricIndex, fabric] of this.previousFabrics) {
664
+ if (!currentFabricMap.has(fabricIndex)) {
665
+ log.info(`Fabric removed: ${fabric.fabricId} (index: ${fabricIndex})`);
666
+ this.emit('fabric-removed', fabric);
667
+ // If this was the last fabric, emit 'decommissioned' event
668
+ if (previousCount === 1 && currentCount === 0) {
669
+ log.info('Bridge decommissioned (last fabric removed)');
670
+ this.emit('decommissioned');
671
+ }
672
+ }
673
+ }
674
+ // Emit general commissioning-changed event if count changed
675
+ if (previousCount !== currentCount) {
676
+ const commissioned = currentCount > 0;
677
+ log.debug(`Commissioning state changed: commissioned=${commissioned}, fabricCount=${currentCount}`);
678
+ this.emit('commissioning-changed', commissioned, currentCount);
679
+ // Update commissioning info file
680
+ this.updateCommissioningFile().catch((error) => {
681
+ log.warn('Failed to update commissioning file:', error);
682
+ });
683
+ }
684
+ // Update previous fabrics map
685
+ this.previousFabrics = currentFabricMap;
686
+ }
687
+ catch (error) {
688
+ log.error('Error checking fabric changes:', error);
689
+ }
690
+ }
691
+ /**
692
+ * Update commissioning info file when commissioning state changes
693
+ */
694
+ async updateCommissioningFile() {
695
+ try {
696
+ if (!this.matterStoragePath) {
697
+ return;
698
+ }
699
+ const commissioningFilePath = path.join(this.matterStoragePath, 'commissioning.json');
700
+ const commissioningData = {
701
+ qrCode: this.commissioningInfo.qrCode,
702
+ manualPairingCode: this.commissioningInfo.manualPairingCode,
703
+ serialNumber: this.serialNumber,
704
+ passcode: this.passcode,
705
+ discriminator: this.discriminator,
706
+ commissioned: this.isCommissioned(),
707
+ fabricCount: this.getCommissionedFabricCount(),
708
+ fabrics: this.getFabricInfo(),
709
+ };
710
+ await writeFile(commissioningFilePath, JSON.stringify(commissioningData, null, 2), 'utf-8');
711
+ log.debug('Updated commissioning info file');
712
+ }
713
+ catch (error) {
714
+ log.debug(`Failed to update commissioning info file: ${error.message}`);
715
+ }
716
+ }
717
+ /**
718
+ * Register Matter platform accessories (Plugin API - matches HAP pattern)
719
+ */
720
+ async registerPlatformAccessories(pluginIdentifier, platformName, accessories) {
721
+ for (const accessory of accessories) {
722
+ await this.registerAccessory(pluginIdentifier, platformName, accessory);
723
+ }
724
+ }
725
+ /**
726
+ * Unregister Matter platform accessories (Plugin API - matches HAP pattern)
727
+ */
728
+ async unregisterPlatformAccessories(pluginIdentifier, platformName, accessories) {
729
+ for (const accessory of accessories) {
730
+ await this.unregisterAccessory(accessory.uuid);
731
+ }
732
+ }
733
+ /**
734
+ * Register a single Matter accessory (internal method)
735
+ */
736
+ async registerAccessory(pluginIdentifier, platformName, accessory) {
737
+ if (!this.serverNode || !this.aggregator) {
738
+ throw new MatterDeviceError('Matter server not started');
739
+ }
740
+ // Validate required fields with helpful error messages
741
+ if (!accessory.deviceType) {
742
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName || 'unknown'}" is missing required field 'deviceType'. `
743
+ + 'Example: deviceType: api.matterDeviceTypes.OnOffLight\n'
744
+ + 'Available device types: OnOffLight, DimmableLight, TemperatureSensor, etc.\n'
745
+ + 'See the Matter types documentation for the full list.');
746
+ }
747
+ if (!accessory.uuid) {
748
+ throw new MatterDeviceError('Matter accessory is missing required field \'uuid\'.\n'
749
+ + 'Generate a unique UUID for your accessory:\n'
750
+ + ' const uuid = api.hap.uuid.generate(\'my-unique-id\')');
751
+ }
752
+ if (!accessory.displayName) {
753
+ throw new MatterDeviceError(`Matter accessory (${accessory.uuid}) is missing required field 'displayName'.\n`
754
+ + 'Example: displayName: \'Living Room Light\'');
755
+ }
756
+ if (!accessory.serialNumber) {
757
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'serialNumber'.\n`
758
+ + 'Example: serialNumber: \'ABC123\' or serialNumber: accessory.UUID');
759
+ }
760
+ if (!accessory.manufacturer) {
761
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'manufacturer'.\n`
762
+ + 'Example: manufacturer: \'Homebridge\' or manufacturer: \'My Plugin Name\'');
763
+ }
764
+ if (!accessory.model) {
765
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'model'.\n`
766
+ + 'Example: model: \'v1.0\' or model: \'Smart Light\'');
767
+ }
768
+ if (!accessory.clusters || typeof accessory.clusters !== 'object') {
769
+ throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing or has invalid 'clusters' field.\n`
770
+ + 'Clusters define the functionality of your device. Example:\n'
771
+ + ' clusters: {\n'
772
+ + ' onOff: { onOff: false },\n'
773
+ + ' levelControl: { currentLevel: 0, minLevel: 0, maxLevel: 254 }\n'
774
+ + ' }');
775
+ }
776
+ // Check if already registered (during this session)
777
+ if (this.accessories.has(accessory.uuid)) {
778
+ const existing = this.accessories.get(accessory.uuid);
779
+ throw new MatterDeviceError(`Matter accessory with UUID "${accessory.uuid}" is already registered.\n`
780
+ + `Existing accessory: "${existing?.displayName}"\n`
781
+ + `New accessory: "${accessory.displayName}"\n`
782
+ + 'Each accessory must have a unique UUID. Use api.hap.uuid.generate() with a unique string.');
783
+ }
784
+ // Check if there's a cached version - merge cached cluster states with new registration
785
+ if (this.accessoryCache && this.accessoryCache.hasCached(accessory.uuid)) {
786
+ const cached = this.accessoryCache.getCached(accessory.uuid);
787
+ if (cached && cached.clusters) {
788
+ // Merge cached cluster states with new ones (prefer cached state to persist values across restarts)
789
+ for (const [clusterName, cachedAttrs] of Object.entries(cached.clusters)) {
790
+ if (!accessory.clusters[clusterName]) {
791
+ // Cluster exists in cache but not in new registration - preserve it
792
+ accessory.clusters[clusterName] = cachedAttrs;
793
+ }
794
+ else {
795
+ // Cluster exists in both - merge (prefer cached state over initial values)
796
+ accessory.clusters[clusterName] = {
797
+ ...accessory.clusters[clusterName],
798
+ ...cachedAttrs,
799
+ };
800
+ }
801
+ }
802
+ // Restore context if available
803
+ if (cached.context && !accessory.context) {
804
+ accessory.context = cached.context;
805
+ }
806
+ log.info(`Restored cached state for Matter accessory: ${accessory.displayName}`);
807
+ }
808
+ }
809
+ // Check device limit
810
+ if (this.accessories.size >= this.MAX_DEVICES) {
811
+ throw new MatterDeviceError(`Cannot register Matter accessory "${accessory.displayName}": `
812
+ + `Maximum device limit reached (${this.MAX_DEVICES} devices).\n`
813
+ + `Current registered devices: ${this.accessories.size}`);
814
+ }
815
+ try {
816
+ // Modify device type with custom behaviors if handlers are defined
817
+ let deviceType = accessory.deviceType;
818
+ // IMPORTANT: Detect WindowCovering features based on accessory attributes
819
+ // This must run BEFORE any other device type modifications
820
+ let windowCoveringFeatures = null;
821
+ if (accessory.clusters.windowCovering) {
822
+ windowCoveringFeatures = [];
823
+ const wcCluster = accessory.clusters.windowCovering;
824
+ // Check for lift attributes
825
+ const hasLiftAttrs = 'targetPositionLiftPercent100ths' in wcCluster || 'currentPositionLiftPercent100ths' in wcCluster;
826
+ const hasConfigLift = wcCluster.configStatus?.liftPositionAware === true;
827
+ // Check for tilt attributes
828
+ const hasTiltAttrs = 'targetPositionTiltPercent100ths' in wcCluster || 'currentPositionTiltPercent100ths' in wcCluster;
829
+ const hasConfigTilt = wcCluster.configStatus?.tiltPositionAware === true;
830
+ log.debug(`[${accessory.displayName}] WindowCovering detection: hasLiftAttrs=${hasLiftAttrs}, hasConfigLift=${hasConfigLift}, hasTiltAttrs=${hasTiltAttrs}, hasConfigTilt=${hasConfigTilt}`);
831
+ // Add Lift features if lift attributes are present
832
+ if (hasLiftAttrs) {
833
+ windowCoveringFeatures.push('Lift');
834
+ if (hasConfigLift) {
835
+ windowCoveringFeatures.push('PositionAwareLift');
836
+ }
837
+ }
838
+ // Add Tilt features if tilt attributes are present
839
+ if (hasTiltAttrs) {
840
+ windowCoveringFeatures.push('Tilt');
841
+ if (hasConfigTilt) {
842
+ windowCoveringFeatures.push('PositionAwareTilt');
843
+ }
844
+ }
845
+ if (windowCoveringFeatures.length > 0) {
846
+ log.info(`Auto-detected WindowCovering features for ${accessory.displayName}: ${windowCoveringFeatures.join(', ')}`);
847
+ // Apply WindowCovering features to base device type using ONLY base WindowCoveringServer
848
+ // Don't use custom behavior for WindowCovering to avoid conflicts
849
+ deviceType = deviceType.with(WindowCoveringServer.with(...windowCoveringFeatures));
850
+ // Set type attribute ONLY for devices with Tilt features
851
+ // Type 8 (TiltBlindLift) supports lift+tilt
852
+ // Default Rollershade (0) works fine for lift-only devices
853
+ const hasTiltFeatures = windowCoveringFeatures.includes('Tilt');
854
+ if (hasTiltFeatures) {
855
+ const wcCluster = accessory.clusters.windowCovering;
856
+ wcCluster.type = 8; // TiltBlindLift
857
+ log.debug('Set WindowCovering type to 8 (TiltBlindLift) for tilt-capable device');
858
+ }
859
+ // Mark that we should skip custom behavior for windowCovering
860
+ if (!accessory.context) {
861
+ accessory.context = {};
862
+ }
863
+ accessory.context._skipWindowCoveringBehavior = true;
864
+ }
865
+ else {
866
+ log.warn(`⚠️ No WindowCovering features detected for ${accessory.displayName}!`);
867
+ }
868
+ }
869
+ if (accessory.handlers) {
870
+ log.warn(`📝 [${accessory.displayName}] Has handlers: ${Object.keys(accessory.handlers).join(', ')}`);
871
+ // IMPORTANT: Detect ColorControl features BEFORE modifying device type
872
+ // Once we start applying custom behaviors, the original structure is lost
873
+ let colorControlFeatures = null;
874
+ if (accessory.handlers.colorControl) {
875
+ const deviceTypeDef = deviceType;
876
+ const existingBehaviors = deviceTypeDef.behaviors;
877
+ if (existingBehaviors) {
878
+ // Behaviors can be an array, Set, or object - handle all cases
879
+ let behaviorsArray;
880
+ if (Array.isArray(existingBehaviors)) {
881
+ behaviorsArray = existingBehaviors;
882
+ }
883
+ else if (typeof existingBehaviors === 'object') {
884
+ // It's an object - get the values
885
+ behaviorsArray = Object.values(existingBehaviors);
886
+ }
887
+ else {
888
+ // Try Array.from as fallback for Sets, Maps, etc.
889
+ behaviorsArray = Array.from(existingBehaviors);
890
+ }
891
+ for (const behavior of behaviorsArray) {
892
+ if (behavior.id === 'colorControl' || behavior.cluster?.id === 0x0300) {
893
+ if (behavior.cluster?.supportedFeatures) {
894
+ const features = behavior.cluster.supportedFeatures;
895
+ colorControlFeatures = [];
896
+ // supportedFeatures is an object with boolean properties, not a bitmap
897
+ if (features.hueSaturation) {
898
+ colorControlFeatures.push('HueSaturation');
899
+ }
900
+ if (features.xy) {
901
+ colorControlFeatures.push('Xy');
902
+ }
903
+ if (features.colorTemperature) {
904
+ colorControlFeatures.push('ColorTemperature');
905
+ }
906
+ }
907
+ // IMPORTANT: Adjust features based on provided handlers
908
+ // Add missing features and remove features without handlers
909
+ if (accessory.handlers.colorControl) {
910
+ if (!colorControlFeatures) {
911
+ colorControlFeatures = [];
912
+ }
913
+ const handlers = accessory.handlers.colorControl;
914
+ const finalFeatures = [];
915
+ // Check each possible feature and only include it if the handler is provided
916
+ // HueSaturation feature
917
+ if ('moveToHueAndSaturationLogic' in handlers) {
918
+ finalFeatures.push('HueSaturation');
919
+ }
920
+ // Xy feature
921
+ if ('moveToColorLogic' in handlers) {
922
+ finalFeatures.push('Xy');
923
+ }
924
+ // ColorTemperature feature
925
+ if ('moveToColorTemperatureLogic' in handlers) {
926
+ finalFeatures.push('ColorTemperature');
927
+ }
928
+ colorControlFeatures = finalFeatures;
929
+ }
930
+ break;
931
+ }
932
+ }
933
+ }
934
+ else {
935
+ log.warn(`No behaviors found on device type for ${accessory.displayName}`);
936
+ }
937
+ }
938
+ // IMPORTANT: Detect Thermostat features BEFORE modifying device type
939
+ let thermostatFeatures = null;
940
+ if (accessory.handlers.thermostat) {
941
+ const deviceTypeDef = deviceType;
942
+ const existingBehaviors = deviceTypeDef.behaviors;
943
+ if (existingBehaviors) {
944
+ // Behaviors can be an array, Set, or object - handle all cases
945
+ let behaviorsArray;
946
+ if (Array.isArray(existingBehaviors)) {
947
+ behaviorsArray = existingBehaviors;
948
+ }
949
+ else if (typeof existingBehaviors === 'object') {
950
+ // It's an object - get the values
951
+ behaviorsArray = Object.values(existingBehaviors);
952
+ }
953
+ else {
954
+ // Try Array.from as fallback for Sets, Maps, etc.
955
+ behaviorsArray = Array.from(existingBehaviors);
956
+ }
957
+ for (const behavior of behaviorsArray) {
958
+ if (behavior.id === 'thermostat' || behavior.cluster?.id === 0x0201) {
959
+ if (behavior.cluster?.supportedFeatures) {
960
+ const features = behavior.cluster.supportedFeatures;
961
+ thermostatFeatures = [];
962
+ // supportedFeatures is an object with boolean properties
963
+ if (features.heating) {
964
+ thermostatFeatures.push('Heating');
965
+ }
966
+ if (features.cooling) {
967
+ thermostatFeatures.push('Cooling');
968
+ }
969
+ if (features.occupancy) {
970
+ thermostatFeatures.push('Occupancy');
971
+ }
972
+ if (features.autoMode) {
973
+ thermostatFeatures.push('AutoMode');
974
+ }
975
+ log.info(`Detected Thermostat features: ${thermostatFeatures.join(', ')}`);
976
+ }
977
+ break;
978
+ }
979
+ }
980
+ }
981
+ }
982
+ // Map cluster names to custom behavior classes
983
+ // Only clusters with user-triggered commands need custom behaviors
984
+ const behaviorMap = {
985
+ // Core controls
986
+ onOff: HomebridgeOnOffServer,
987
+ levelControl: HomebridgeLevelControlServer,
988
+ colorControl: HomebridgeColorControlServer,
989
+ // Coverings & locks
990
+ windowCovering: HomebridgeWindowCoveringServer,
991
+ doorLock: HomebridgeDoorLockServer,
992
+ // Climate control
993
+ thermostat: HomebridgeThermostatServer,
994
+ // Identification
995
+ identify: HomebridgeIdentifyServer,
996
+ };
997
+ // Build array of custom behaviors to apply based on what handlers are defined
998
+ const customBehaviors = [];
999
+ for (const clusterName of Object.keys(accessory.handlers)) {
1000
+ // Skip windowCovering if we already applied features via base WindowCoveringServer
1001
+ if (clusterName === 'windowCovering' && accessory.context?._skipWindowCoveringBehavior) {
1002
+ log.debug('Skipping custom WindowCovering behavior (using base server with features instead)');
1003
+ continue;
1004
+ }
1005
+ let behaviorClass = behaviorMap[clusterName];
1006
+ // Apply ColorControl features if we detected them earlier
1007
+ if (clusterName === 'colorControl' && behaviorClass && colorControlFeatures && colorControlFeatures.length > 0) {
1008
+ behaviorClass = behaviorClass.with(...colorControlFeatures);
1009
+ log.info(`ColorControl custom behavior will preserve features: ${colorControlFeatures.join(', ')}`);
1010
+ }
1011
+ // Apply Thermostat features if we detected them earlier
1012
+ if (clusterName === 'thermostat' && behaviorClass && thermostatFeatures && thermostatFeatures.length > 0) {
1013
+ behaviorClass = behaviorClass.with(...thermostatFeatures);
1014
+ log.info(`Thermostat custom behavior will preserve features: ${thermostatFeatures.join(', ')}`);
1015
+ }
1016
+ // Apply WindowCovering features to custom behavior as well
1017
+ // (features were already applied to base device type, but custom behavior needs them too)
1018
+ if (clusterName === 'windowCovering') {
1019
+ log.warn(`🔍 WindowCovering handler found: behaviorClass=${!!behaviorClass}, windowCoveringFeatures=${windowCoveringFeatures}, length=${windowCoveringFeatures?.length}`);
1020
+ if (behaviorClass && windowCoveringFeatures && windowCoveringFeatures.length > 0) {
1021
+ behaviorClass = behaviorClass.with(...windowCoveringFeatures);
1022
+ log.warn(`🔧 WindowCovering custom behavior will have features: ${windowCoveringFeatures.join(', ')}`);
1023
+ }
1024
+ else {
1025
+ log.warn(`⚠️ Skipping WindowCovering feature application: behaviorClass=${!!behaviorClass}, features=${windowCoveringFeatures}`);
1026
+ }
1027
+ }
1028
+ if (behaviorClass) {
1029
+ customBehaviors.push(behaviorClass);
1030
+ log.info(`Will use ${behaviorClass.name} for ${accessory.displayName}`);
1031
+ }
1032
+ else {
1033
+ log.warn(`No custom behavior class available for cluster '${clusterName}' - handlers will be registered but may not be called`);
1034
+ }
1035
+ }
1036
+ if (customBehaviors.length > 0) {
1037
+ // Cast to any to bypass TypeScript limitations
1038
+ deviceType = deviceType.with(...customBehaviors);
1039
+ log.info(`Applied ${customBehaviors.length} custom behavior(s) to device type`);
1040
+ }
1041
+ }
1042
+ // Create endpoint with the modified device type AND initial cluster states
1043
+ // IMPORTANT: Cluster states must be provided at creation time, before adding to aggregator
1044
+ // This ensures mandatory attributes are set before Matter.js validates the endpoint
1045
+ const endpointOptions = {
1046
+ id: accessory.uuid,
1047
+ ...accessory.clusters, // Spread cluster states as initial values
1048
+ };
1049
+ const endpoint = new Endpoint(deviceType, endpointOptions);
1050
+ if (this.config.debugModeEnabled) {
1051
+ log.debug(`Created endpoint for ${accessory.displayName} with initial cluster states`);
1052
+ }
1053
+ // Add to aggregator (validation happens here - cluster states must already be set)
1054
+ await this.aggregator.add(endpoint);
1055
+ if (this.config.debugModeEnabled) {
1056
+ log.debug(`Added endpoint for ${accessory.displayName} to aggregator`);
1057
+ }
1058
+ // Set up command handlers if provided
1059
+ if (accessory.handlers) {
1060
+ log.info(`Setting up handlers for accessory ${accessory.uuid}`);
1061
+ // Register handlers with the custom behavior classes
1062
+ for (const [clusterName, handlers] of Object.entries(accessory.handlers)) {
1063
+ log.info(` Processing cluster: ${clusterName}`);
1064
+ for (const [commandName, handler] of Object.entries(handlers)) {
1065
+ registerHandler(accessory.uuid, clusterName, commandName, handler);
1066
+ }
1067
+ }
1068
+ }
1069
+ // Store accessory
1070
+ const internalAccessory = {
1071
+ ...accessory,
1072
+ _associatedPlugin: pluginIdentifier,
1073
+ _associatedPlatform: platformName,
1074
+ endpoint,
1075
+ registered: true,
1076
+ };
1077
+ this.accessories.set(accessory.uuid, internalAccessory);
1078
+ log.info(`Registered Matter accessory: ${accessory.displayName} (${accessory.uuid})`);
1079
+ if (this.config.debugModeEnabled) {
1080
+ log.debug(`Total registered accessories: ${this.accessories.size}/${this.MAX_DEVICES}`);
1081
+ }
1082
+ // Emit accessory-registered event
1083
+ this.emit('accessory-registered', accessory);
1084
+ // Notify controllers about the new device (parts list changed)
1085
+ // This allows the Home app to discover new devices without re-pairing
1086
+ await this.notifyPartsListChanged();
1087
+ // Save to cache asynchronously (don't block registration)
1088
+ if (this.accessoryCache) {
1089
+ this.accessoryCache.save(this.accessories).catch((error) => {
1090
+ log.warn('Failed to save accessory cache:', error);
1091
+ });
1092
+ }
1093
+ }
1094
+ catch (error) {
1095
+ log.error(`Failed to register Matter accessory ${accessory.displayName}:`, error);
1096
+ throw new MatterDeviceError(`Failed to register accessory: ${error}`);
1097
+ }
1098
+ }
1099
+ /**
1100
+ * Unregister a Matter accessory (Plugin API)
1101
+ */
1102
+ async unregisterAccessory(uuid) {
1103
+ const accessory = this.accessories.get(uuid);
1104
+ if (!accessory) {
1105
+ log.debug(`Accessory ${uuid} not found, ignoring unregister request`);
1106
+ return;
1107
+ }
1108
+ try {
1109
+ if (accessory.endpoint && this.aggregator) {
1110
+ await accessory.endpoint.close();
1111
+ log.debug(`Removed endpoint for ${accessory.displayName}`);
1112
+ }
1113
+ this.accessories.delete(uuid);
1114
+ log.info(`Unregistered Matter accessory: ${accessory.displayName} (${uuid})`);
1115
+ // Emit accessory-unregistered event
1116
+ this.emit('accessory-unregistered', uuid);
1117
+ // Notify controllers about the removed device (parts list changed)
1118
+ await this.notifyPartsListChanged();
1119
+ // Update cache (remove the accessory)
1120
+ if (this.accessoryCache) {
1121
+ this.accessoryCache.removeCached(uuid);
1122
+ this.accessoryCache.save(this.accessories).catch((error) => {
1123
+ log.warn('Failed to save accessory cache:', error);
1124
+ });
1125
+ }
1126
+ }
1127
+ catch (error) {
1128
+ log.error(`Failed to unregister Matter accessory ${uuid}:`, error);
1129
+ throw new MatterDeviceError(`Failed to unregister accessory: ${error}`);
1130
+ }
1131
+ }
1132
+ /**
1133
+ * Update a Matter accessory's state (Plugin API)
1134
+ *
1135
+ * This method can be called from anywhere, including from within handlers.
1136
+ * State updates are automatically deferred to avoid transaction conflicts.
1137
+ */
1138
+ async updateAccessoryState(uuid, cluster, attributes) {
1139
+ const accessory = this.accessories.get(uuid);
1140
+ if (!accessory || !accessory.endpoint) {
1141
+ throw new MatterDeviceError(`Accessory ${uuid} not found or not registered`);
1142
+ }
1143
+ // Defer the update to avoid "read-only transaction" errors when called from handlers
1144
+ // Matter.js uses transactions, and we need to wait until the transaction fully completes
1145
+ // Use setTimeout with a delay to ensure we're completely outside the transaction
1146
+ return new Promise((resolve, reject) => {
1147
+ setTimeout(async () => {
1148
+ try {
1149
+ // Use endpoint.set() method which is the proper way to update state
1150
+ // This handles transactions correctly
1151
+ const endpoint = accessory.endpoint;
1152
+ // Construct the update object
1153
+ const updateObject = { [cluster]: attributes };
1154
+ // Use endpoint.set() which properly handles state updates
1155
+ await endpoint.set(updateObject);
1156
+ // CRITICAL: Also update the cached clusters object so state persists across restarts
1157
+ // Merge the new attributes into the existing cluster state
1158
+ if (!accessory.clusters[cluster]) {
1159
+ accessory.clusters[cluster] = {};
1160
+ }
1161
+ accessory.clusters[cluster] = {
1162
+ ...accessory.clusters[cluster],
1163
+ ...attributes,
1164
+ };
1165
+ log.debug(`Updated ${cluster} state for ${accessory.displayName}:`, attributes);
1166
+ resolve();
1167
+ }
1168
+ catch (error) {
1169
+ log.error(`Failed to update state for accessory ${uuid}:`, error);
1170
+ reject(new MatterDeviceError(`Failed to update accessory state: ${error}`));
1171
+ }
1172
+ }, 50); // 50ms delay to ensure we're completely outside the transaction
1173
+ });
1174
+ }
1175
+ /**
1176
+ * Get a Matter accessory's current state (Plugin API)
1177
+ *
1178
+ * Returns the current cluster attribute values that are exposed to Matter controllers.
1179
+ * This is useful for:
1180
+ * - Reading state after plugin restart (when local variables are lost)
1181
+ * - Verifying current state before making changes
1182
+ * - Multiple parts of code that need to read state
1183
+ * - Debugging and logging
1184
+ *
1185
+ * @param uuid - The UUID of the accessory
1186
+ * @param cluster - The cluster name (e.g., 'onOff', 'levelControl')
1187
+ * @returns Current cluster attribute values, or undefined if cluster not found
1188
+ */
1189
+ getAccessoryState(uuid, cluster) {
1190
+ const accessory = this.accessories.get(uuid);
1191
+ if (!accessory || !accessory.endpoint) {
1192
+ log.debug(`Accessory ${uuid} not found or not registered`);
1193
+ return undefined;
1194
+ }
1195
+ try {
1196
+ const endpoint = accessory.endpoint;
1197
+ if (!endpoint.state) {
1198
+ log.debug(`endpoint.state is undefined for ${accessory.displayName}`);
1199
+ return undefined;
1200
+ }
1201
+ if (!endpoint.state[cluster]) {
1202
+ const availableClusters = Object.keys(endpoint.state || {});
1203
+ log.debug(`Cluster '${cluster}' not found on ${accessory.displayName}. Available: ${availableClusters.join(', ')}`);
1204
+ return undefined;
1205
+ }
1206
+ const clusterState = endpoint.state[cluster];
1207
+ // Build result object by reading each property directly
1208
+ const result = {};
1209
+ // Get list of properties to read - use both approaches for maximum compatibility
1210
+ const allKeys = new Set([
1211
+ ...Object.keys(clusterState),
1212
+ ...Object.getOwnPropertyNames(clusterState),
1213
+ ]);
1214
+ for (const key of allKeys) {
1215
+ try {
1216
+ // Skip internal properties, methods, and symbols
1217
+ if (key.startsWith('_') || key.startsWith('$')) {
1218
+ continue;
1219
+ }
1220
+ // Try to read the value directly
1221
+ const value = clusterState[key];
1222
+ // Skip functions and undefined values
1223
+ if (typeof value === 'function' || value === undefined) {
1224
+ continue;
1225
+ }
1226
+ result[key] = value;
1227
+ }
1228
+ catch (propError) {
1229
+ log.debug(`Could not read property ${key} from ${cluster}:`, propError);
1230
+ }
1231
+ }
1232
+ if (Object.keys(result).length === 0) {
1233
+ log.debug(`Cluster ${cluster} found but no readable properties on accessory ${accessory.displayName}`);
1234
+ return undefined;
1235
+ }
1236
+ return result;
1237
+ }
1238
+ catch (error) {
1239
+ log.error(`Failed to get state for accessory ${uuid}:`, error);
1240
+ return undefined;
1241
+ }
1242
+ }
1243
+ /**
1244
+ * Get all cached accessories (Internal - for restore process)
1245
+ * @internal
1246
+ */
1247
+ getAllCachedAccessories() {
1248
+ if (!this.accessoryCache) {
1249
+ log.debug('getAllCachedAccessories: No cache available');
1250
+ return [];
1251
+ }
1252
+ const cached = Array.from(this.accessoryCache.getAllCached().values());
1253
+ log.debug(`getAllCachedAccessories: Returning ${cached.length} accessories`);
1254
+ return cached;
1255
+ }
1256
+ /**
1257
+ * Get all registered accessories (Plugin API)
1258
+ */
1259
+ getAccessories() {
1260
+ return Array.from(this.accessories.values()).map((acc) => {
1261
+ // Return copy without internal fields
1262
+ // eslint-disable-next-line unused-imports/no-unused-vars
1263
+ const { endpoint, registered, ...publicAccessory } = acc;
1264
+ return publicAccessory;
1265
+ });
1266
+ }
1267
+ /**
1268
+ * Get a specific accessory by UUID (Plugin API)
1269
+ */
1270
+ getAccessory(uuid) {
1271
+ const accessory = this.accessories.get(uuid);
1272
+ if (!accessory) {
1273
+ return undefined;
1274
+ }
1275
+ // Return copy without internal fields
1276
+ // eslint-disable-next-line unused-imports/no-unused-vars
1277
+ const { endpoint, registered, ...publicAccessory } = accessory;
1278
+ return publicAccessory;
1279
+ }
1280
+ /**
1281
+ * Stop the Matter server
1282
+ */
1283
+ async stop() {
1284
+ if (!this.isRunning) {
1285
+ log.debug('Matter server is not running');
1286
+ return;
1287
+ }
1288
+ this.isRunning = false;
1289
+ // Stop monitoring
1290
+ this.stopFabricMonitoring();
1291
+ networkMonitor.stopMonitoring();
1292
+ try {
1293
+ // Save accessory cache before shutting down (BEFORE clearing accessories!)
1294
+ if (this.accessoryCache && this.accessories.size > 0) {
1295
+ await this.accessoryCache.save(this.accessories);
1296
+ log.debug('Saved accessory cache before shutdown');
1297
+ }
1298
+ // Clean up all accessories
1299
+ for (const accessory of this.accessories.values()) {
1300
+ try {
1301
+ if (accessory.endpoint) {
1302
+ await accessory.endpoint.close();
1303
+ }
1304
+ }
1305
+ catch (error) {
1306
+ log.error('Failed to clean up accessory:', error);
1307
+ }
1308
+ }
1309
+ this.accessories.clear();
1310
+ // Stop server
1311
+ if (this.serverNode) {
1312
+ await this.serverNode.close();
1313
+ }
1314
+ await this.cleanup();
1315
+ log.info('Matter server stopped');
1316
+ }
1317
+ catch (error) {
1318
+ log.error('Error stopping Matter server:', error);
1319
+ await errorHandler.handleError(error, 'server-stop');
1320
+ throw error;
1321
+ }
1322
+ finally {
1323
+ this.isRunning = false;
1324
+ }
1325
+ }
1326
+ /**
1327
+ * Cleanup resources
1328
+ */
1329
+ async cleanup() {
1330
+ // Remove signal handlers
1331
+ if (this.shutdownHandler) {
1332
+ process.off('SIGINT', this.shutdownHandler);
1333
+ process.off('SIGTERM', this.shutdownHandler);
1334
+ this.shutdownHandler = null;
1335
+ }
1336
+ // Run all cleanup handlers
1337
+ for (const handler of this.cleanupHandlers) {
1338
+ try {
1339
+ await handler();
1340
+ }
1341
+ catch (error) {
1342
+ log.debug('Error during cleanup handler:', error);
1343
+ }
1344
+ }
1345
+ this.cleanupHandlers = [];
1346
+ // Clear references
1347
+ this.serverNode = null;
1348
+ this.aggregator = null;
1349
+ this.isRunning = false;
1350
+ this.commissioningInfo = {};
1351
+ }
1352
+ /**
1353
+ * Get fabric information for commissioned controllers
1354
+ */
1355
+ getFabricInfo() {
1356
+ try {
1357
+ if (!this.serverNode || !this.storageManager) {
1358
+ return [];
1359
+ }
1360
+ // Fabric data is stored in the main storage file (uniqueId namespace)
1361
+ // with the key "fabrics.fabrics"
1362
+ const storage = this.storageManager.getStorage(this.config.uniqueId);
1363
+ const fabricsData = storage.get(['fabrics'], 'fabrics');
1364
+ if (Array.isArray(fabricsData) && fabricsData.length > 0) {
1365
+ // Map the fabric data to our interface
1366
+ return fabricsData.map(fabric => ({
1367
+ fabricIndex: fabric.fabricIndex || 0,
1368
+ fabricId: fabric.fabricId?.value?.toString() || '',
1369
+ nodeId: fabric.nodeId?.value?.toString() || '',
1370
+ rootVendorId: fabric.rootVendorId || 0,
1371
+ label: fabric.label || '',
1372
+ }));
1373
+ }
1374
+ return [];
1375
+ }
1376
+ catch (error) {
1377
+ log.debug('Failed to get fabric info from storage:', error);
1378
+ return [];
1379
+ }
1380
+ }
1381
+ /**
1382
+ * Check if the server is commissioned
1383
+ */
1384
+ isCommissioned() {
1385
+ try {
1386
+ if (!this.storageManager) {
1387
+ return false;
1388
+ }
1389
+ // Commissioned status is stored in the main storage file (uniqueId namespace)
1390
+ // at key "root.commissioning.commissioned"
1391
+ const storage = this.storageManager.getStorage(this.config.uniqueId);
1392
+ const commissioned = storage.get(['root', 'commissioning'], 'commissioned');
1393
+ if (commissioned === true) {
1394
+ return true;
1395
+ }
1396
+ // Fallback to checking fabric count if commissioned flag not found
1397
+ const fabrics = this.getFabricInfo();
1398
+ return fabrics.length > 0;
1399
+ }
1400
+ catch (error) {
1401
+ log.debug('Failed to check commissioned status from storage:', error);
1402
+ return false;
1403
+ }
1404
+ }
1405
+ /**
1406
+ * Get the number of commissioned fabrics
1407
+ */
1408
+ getCommissionedFabricCount() {
1409
+ return this.getFabricInfo().length;
1410
+ }
1411
+ /**
1412
+ * Get server status information
1413
+ */
1414
+ getServerInfo() {
1415
+ return {
1416
+ running: this.isRunning,
1417
+ port: this.config.port || 5540,
1418
+ deviceCount: this.accessories.size,
1419
+ commissioned: this.isCommissioned(),
1420
+ fabricCount: this.getCommissionedFabricCount(),
1421
+ serialNumber: this.serialNumber,
1422
+ };
1423
+ }
1424
+ /**
1425
+ * Get commissioning information
1426
+ */
1427
+ getCommissioningInfo() {
1428
+ return {
1429
+ ...this.commissioningInfo,
1430
+ serialNumber: this.serialNumber,
1431
+ passcode: this.passcode,
1432
+ discriminator: this.discriminator,
1433
+ commissioned: this.isCommissioned(),
1434
+ };
1435
+ }
1436
+ /**
1437
+ * Get storage statistics
1438
+ */
1439
+ getStorageStats() {
1440
+ if (!this.storageManager) {
1441
+ return null;
1442
+ }
1443
+ return this.storageManager.getAllStats();
1444
+ }
1445
+ /**
1446
+ * Check if server is running
1447
+ */
1448
+ isServerRunning() {
1449
+ return this.isRunning;
1450
+ }
1451
+ /**
1452
+ * Get Matter device types available for plugin use
1453
+ */
1454
+ getDeviceTypes() {
1455
+ return deviceTypes;
1456
+ }
1457
+ /**
1458
+ * Get Matter clusters available for plugin use
1459
+ */
1460
+ getClusters() {
1461
+ return clusters;
1462
+ }
1463
+ /**
1464
+ * Remove a specific fabric (controller) from the bridge
1465
+ * This decommissions a single controller while leaving others intact
1466
+ *
1467
+ * @param fabricIndex - The fabric index to remove
1468
+ * @returns Promise that resolves when the fabric is removed
1469
+ */
1470
+ async removeFabric(fabricIndex) {
1471
+ if (!this.serverNode) {
1472
+ throw new MatterDeviceError('Matter server not started');
1473
+ }
1474
+ try {
1475
+ log.info(`Removing fabric ${fabricIndex}...`);
1476
+ const serverState = this.serverNode;
1477
+ const removeFabric = serverState?.state?.commissioning?.removeFabric;
1478
+ if (typeof removeFabric !== 'function') {
1479
+ throw new MatterDeviceError('Fabric removal not supported by Matter.js version');
1480
+ }
1481
+ // Remove the fabric
1482
+ await removeFabric(fabricIndex);
1483
+ log.info(`Fabric ${fabricIndex} removed successfully`);
1484
+ // The fabric monitoring will detect this change and emit the appropriate events
1485
+ }
1486
+ catch (error) {
1487
+ log.error(`Failed to remove fabric ${fabricIndex}:`, error);
1488
+ throw new MatterDeviceError(`Failed to remove fabric: ${error.message}`, error);
1489
+ }
1490
+ }
1491
+ /**
1492
+ * Check if a specific fabric exists
1493
+ */
1494
+ hasFabric(fabricIndex) {
1495
+ const fabrics = this.getFabricInfo();
1496
+ return fabrics.some(f => f.fabricIndex === fabricIndex);
1497
+ }
1498
+ /**
1499
+ * Notify controllers that the parts list has changed
1500
+ * This triggers controllers (like Home app) to re-read the device list
1501
+ * and discover new or removed accessories without needing to re-pair
1502
+ */
1503
+ async notifyPartsListChanged() {
1504
+ if (!this.aggregator || !this.isCommissioned()) {
1505
+ // No controllers connected, skip notification
1506
+ return;
1507
+ }
1508
+ try {
1509
+ // Access the aggregator's descriptor cluster state
1510
+ // The partsList is automatically updated by Matter.js when endpoints are added/removed
1511
+ // We just need to ensure controllers are notified of the change
1512
+ const aggregatorState = this.aggregator;
1513
+ if (aggregatorState.state?.descriptor) {
1514
+ // Get current parts list (endpoint numbers of all children)
1515
+ const partsList = aggregatorState.state.descriptor.partsList || [];
1516
+ if (this.config.debugModeEnabled) {
1517
+ log.debug(`Parts list changed: ${partsList.length} devices (endpoints: ${partsList.join(', ')})`);
1518
+ }
1519
+ // Trigger a state update event to notify subscribed controllers
1520
+ // By setting the partsList to itself, we trigger the change notification
1521
+ await this.aggregator.set({
1522
+ descriptor: {
1523
+ partsList,
1524
+ },
1525
+ });
1526
+ log.info(`Notified controllers of parts list change (${this.accessories.size} devices)`);
1527
+ }
1528
+ }
1529
+ catch (error) {
1530
+ // Non-fatal error - log but don't throw
1531
+ log.warn(`Failed to notify controllers of parts list change: ${error.message}`);
1532
+ }
1533
+ }
1534
+ }
1535
+ //# sourceMappingURL=matterServer.js.map