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