homebridge
Version:
HomeKit support for the impatient
475 lines • 26 kB
JavaScript
/**
* Matter Bridge Manager
*
* Manages Matter server lifecycle and accessories for the main Homebridge bridge.
* This class extracts Matter-specific logic from server.ts to minimize changes to core files.
*/
import { DEFAULT_BRIDGE_DEFAULTS } from '../bridgeService.js';
import { Logger } from '../logger.js';
import { User } from '../user.js';
import getVersion from '../version.js';
import { BaseMatterManager } from './BaseMatterManager.js';
import { publishExternalMatterAccessory } from './ExternalMatterAccessoryPublisher.js';
import { MatterServer } from './server.js';
import { getErrorCode, getMatterJsVersion, normalizeBindConfig } from './utils.js';
const log = Logger.withPrefix('Matter/MainManager');
const COLON_RE = /:/g;
/**
* Manages Matter server and accessories for the main bridge
*/
export class MatterBridgeManager extends BaseMatterManager {
config;
api;
externalPortService;
options;
server;
constructor(config, api, externalPortService, pluginManager, options, server) {
super(pluginManager);
this.config = config;
this.api = api;
this.externalPortService = externalPortService;
this.options = options;
this.server = server;
this.setupEventListeners();
}
// Stored listener references so they can be removed in teardown()
_onPublishExternalMatterAccessories = (accessories, registrationId) => {
this.handlePublishExternalAccessories(accessories, registrationId).catch((error) => {
log.error('Failed to publish external Matter accessories:', error);
this.api._resolveExternalRegistration(registrationId);
});
};
_onRegisterMatterPlatformAccessories = (pluginIdentifier, platformName, accessories) => {
this.handleRegisterPlatformAccessories(pluginIdentifier, platformName, accessories).catch((error) => {
log.error(`Failed to register Matter accessories for ${pluginIdentifier}:`, error);
});
};
_onUpdateMatterPlatformAccessories = (accessories) => {
this.handleUpdatePlatformAccessories(accessories).catch((error) => {
log.error('Failed to update Matter platform accessories:', error);
});
};
_onUnregisterMatterPlatformAccessories = (pluginIdentifier, platformName, accessories) => {
this.handleUnregisterPlatformAccessories(pluginIdentifier, platformName, accessories).catch((error) => {
log.error(`Failed to unregister Matter accessories for ${pluginIdentifier}:`, error);
});
};
_onUnregisterExternalMatterAccessories = (accessories) => {
this.handleUnregisterExternalAccessories(accessories).catch((error) => {
log.error('Failed to unregister external Matter accessories:', error);
});
};
_onUpdateMatterAccessoryState = (uuid, cluster, attributes, partId) => {
this.handleUpdateAccessoryState(uuid, cluster, attributes, partId).catch((error) => {
log.error('Failed to update Matter accessory state:', error);
});
};
// Stored reference so the stateChange listener can be removed in teardown()
_onMatterServerStateChange = ({ uuid, cluster, state, partId }) => {
const event = {
type: 'accessoryUpdate',
data: { uuid, cluster, state, partId },
};
this.server.ipcService.sendMessage("matterEvent" /* IpcOutgoingEvent.MATTER_EVENT */, event);
};
/**
* Set up event listeners for Matter accessory operations
* Subscribes directly to API events instead of requiring server.ts to delegate
*/
setupEventListeners() {
this.api.on("publishExternalMatterAccessories" /* InternalAPIEvent.PUBLISH_EXTERNAL_MATTER_ACCESSORIES */, this._onPublishExternalMatterAccessories);
this.api.on("registerMatterPlatformAccessories" /* InternalAPIEvent.REGISTER_MATTER_PLATFORM_ACCESSORIES */, this._onRegisterMatterPlatformAccessories);
this.api.on("updateMatterPlatformAccessories" /* InternalAPIEvent.UPDATE_MATTER_PLATFORM_ACCESSORIES */, this._onUpdateMatterPlatformAccessories);
this.api.on("unregisterMatterPlatformAccessories" /* InternalAPIEvent.UNREGISTER_MATTER_PLATFORM_ACCESSORIES */, this._onUnregisterMatterPlatformAccessories);
this.api.on("unregisterExternalMatterAccessories" /* InternalAPIEvent.UNREGISTER_EXTERNAL_MATTER_ACCESSORIES */, this._onUnregisterExternalMatterAccessories);
this.api.on("updateMatterAccessoryState" /* InternalAPIEvent.UPDATE_MATTER_ACCESSORY_STATE */, this._onUpdateMatterAccessoryState);
}
/**
* Initialize Matter server for main bridge if enabled
*/
async initialize() {
// Check if main bridge has matter configuration
if (!this.config.bridge.matter) {
return;
}
// Declare matterPort outside try block so it's accessible in catch
let matterPort;
try {
log.info('Initializing Matter server for main bridge...');
// Allocate port from pool if not explicitly configured
matterPort = this.config.bridge.matter.port;
if (!matterPort) {
matterPort = await this.externalPortService.requestPort(`${this.config.bridge.username}:MATTER`);
if (!matterPort) {
matterPort = 5540; // Default Matter port
log.warn('No port available from pool for main Matter bridge, using default port 5540');
}
else {
log.info(`Allocated port ${matterPort} from pool for main Matter bridge`);
}
}
// Create Matter server instance with config inheritance from main bridge
const serialNumber = this.config.bridge.username.replace(COLON_RE, '');
// Normalize bind config to array format
const networkInterfaces = normalizeBindConfig(this.config.bridge.bind);
this.matterServer = new MatterServer({
storagePath: User.matterPath(),
port: matterPort,
uniqueId: serialNumber,
displayName: this.config.bridge.name || 'Main Bridge',
manufacturer: this.config.bridge.manufacturer || DEFAULT_BRIDGE_DEFAULTS.manufacturer,
model: this.config.bridge.model || DEFAULT_BRIDGE_DEFAULTS.model,
firmwareRevision: this.config.bridge.firmwareRevision || getVersion(),
serialNumber,
debugModeEnabled: this.options.debugModeEnabled,
networkInterfaces,
});
// Start the Matter server
await this.matterServer.start();
// Log Homebridge and Matter.js version info, matching child bridge log style
const matterJsVersion = await getMatterJsVersion();
log.success('Homebridge v%s (Matter.js v%s) (%s) is running on port %s.', getVersion(), matterJsVersion, this.config.bridge.name, matterPort);
log.info('Matter server initialized for main bridge');
// Inform the API that Matter is enabled
this.api._setMatterEnabled(true);
// Set the Matter server reference for API methods like getAccessoryState
this.api._setMatterServer(this.matterServer);
// Listen for state changes and forward to UI via IPC
this.matterServer.on('stateChange', this._onMatterServerStateChange);
}
catch (error) {
log.error('Failed to initialize Matter server for main bridge:', error);
// Provide user-friendly guidance for common errors
const errorMessage = error instanceof Error ? error.message : String(error);
const errorCode = getErrorCode(error);
if (errorMessage.includes('corrupted')) {
log.error('');
log.error('╔════════════════════════════════════════════════════════════════════════════╗');
log.error('║ MATTER STORAGE CORRUPTED ║');
log.error('╠════════════════════════════════════════════════════════════════════════════╣');
log.error('║ Your Matter storage has become corrupted. This can happen when: ║');
log.error('║ • Matter.js library version changes ║');
log.error('║ • Storage format upgrades occur ║');
log.error('║ • Incomplete writes during shutdown ║');
log.error('║ ║');
log.error('║ To fix this, delete the corrupted storage directory: ║');
log.error(`║ rm -rf ~/.homebridge/matter/${this.config.bridge.username} ║`);
log.error('║ ║');
log.error('║ Note: You will need to re-pair your Matter devices after deletion. ║');
log.error('╚════════════════════════════════════════════════════════════════════════════╝');
log.error('');
}
else if (errorCode === 'EADDRINUSE' || errorMessage.includes('address already in use')) {
log.error('');
log.error('╔════════════════════════════════════════════════════════════════════════════╗');
log.error('║ MATTER PORT ALREADY IN USE ║');
log.error('╠════════════════════════════════════════════════════════════════════════════╣');
log.error(`║ Port ${matterPort} is already in use by another application. ║`);
log.error('║ ║');
log.error('║ To fix this: ║');
log.error('║ 1. Stop the application using this port, or ║');
log.error('║ 2. Configure a different port in your config.json: ║');
log.error('║ "bridge": { ║');
log.error('║ "matter": { ║');
log.error('║ "port": <different-port> ║');
log.error('║ } ║');
log.error('║ } ║');
log.error('╚════════════════════════════════════════════════════════════════════════════╝');
log.error('');
}
}
}
/**
* Handle external Matter accessories - each gets its own dedicated Matter server
* This is required for devices like Robotic Vacuum Cleaners that Apple Home
* requires to be on their own bridge.
*/
async handlePublishExternalAccessories(accessories, registrationId) {
log.info(`Publishing ${accessories.length} external Matter accessor${accessories.length === 1 ? 'y' : 'ies'}`);
// Normalize bind config to array format (inherit from main bridge)
const networkInterfaces = normalizeBindConfig(this.config.bridge.bind);
try {
for (const accessory of accessories) {
try {
// Check if already published
if (this.externalMatterServers.has(accessory.UUID)) {
log.warn(`External Matter accessory ${accessory.displayName} (${accessory.UUID}) is already published`);
continue;
}
// Publish the accessory using shared helper
const result = await publishExternalMatterAccessory(accessory, {
portService: this.externalPortService,
networkInterfaces,
debugModeEnabled: this.options.debugModeEnabled,
});
if (!result) {
// Validation or publishing failed (errors already logged by helper)
continue;
}
// Store the server instance
this.externalMatterServers.set(accessory.UUID, result.server);
// Listen for state changes and forward to UI via IPC
// (same pattern as the main bridge server listener in initialize())
result.server.on('stateChange', this._onMatterServerStateChange);
// Register the external bridge username for direct routing
// Use main bridge's username for consistent lookups
this.server.registerExternalMatterBridge(result.username, this.config.bridge.username);
// Emit the 'ready' event to notify plugins that the accessory is now available on the network
// This is similar to HAP's 'advertised' event and signals that the Matter server is running
// and the accessory can be commissioned by Matter controllers
if (accessory._eventEmitter) {
accessory._eventEmitter.emit('ready', result.port);
}
// Log commissioning info
if (result.commissioningInfo.qrCode && result.commissioningInfo.manualPairingCode) {
log.info(`📱 Commissioning codes for ${accessory.displayName}:`);
log.info(` QR Code: ${result.commissioningInfo.qrCode}`);
log.info(` Manual Code: ${result.commissioningInfo.manualPairingCode}`);
}
}
catch (error) {
log.error(`Failed to publish external Matter accessory ${accessory.displayName}:`, error);
}
}
}
finally {
// Notify that registration is complete (whether successful or not)
this.api._resolveExternalRegistration(registrationId);
}
}
/**
* Get Matter server status information for IPC communication
*/
getMatterStatus() {
// Include Matter commissioning info if Matter is enabled
if (this.matterServer) {
const commissioningInfo = this.matterServer.getCommissioningInfo();
return {
enabled: true,
port: this.config.bridge.matter?.port,
setupUri: commissioningInfo.qrCode,
pin: commissioningInfo.manualPairingCode,
serialNumber: commissioningInfo.serialNumber,
commissioned: commissioningInfo.commissioned || false,
deviceCount: this.matterServer.getAccessories().length,
};
}
else if (this.config.bridge.matter) {
// Matter is configured but not yet started (or failed to start)
return {
enabled: false,
port: this.config.bridge.matter?.port,
};
}
return {
enabled: false,
};
}
/**
* Collect all Matter accessories from all sources
*
* @param bridgeUsername - Optional: specific bridge username to filter by
* @returns Array of accessory data suitable for UI consumption
*/
collectAllAccessories(bridgeUsername) {
const accessories = [];
// Main bridge accessories (if no specific bridge requested or requesting main bridge)
if (!bridgeUsername || bridgeUsername === this.config.bridge.username) {
if (this.matterServer) {
const mainAccessories = this.collectAccessoriesFromServer(this.matterServer, this.config.bridge.username, 'main', this.config.bridge.name || 'Homebridge');
accessories.push(...mainAccessories);
// External accessories (belong to main bridge context)
for (const server of this.externalMatterServers.values()) {
const externalAccessories = this.collectAccessoriesFromServer(server, server.username, 'external', server.bridgeName);
accessories.push(...externalAccessories);
}
}
}
return accessories;
}
/**
* Get detailed info for a specific Matter accessory
*
* @param uuid - Accessory UUID
* @returns Accessory info or undefined if not found
*/
getAccessoryInfo(uuid) {
// Search main bridge
if (this.matterServer) {
const accessory = this.getAccessoryDetailFromServer(this.matterServer, uuid, this.config.bridge.username, 'main');
if (accessory) {
return accessory;
}
}
// Search external servers
for (const server of this.externalMatterServers.values()) {
const accessory = this.getAccessoryDetailFromServer(server, uuid, server.username, 'external');
if (accessory) {
return accessory;
}
}
return undefined;
}
/**
* Collect accessories from a specific Matter server
*
* @param server - Matter server instance
* @param bridgeUsername - Bridge MAC address
* @param bridgeType - Type of bridge (main/child/external)
* @param bridgeName - Display name of the bridge
* @returns Array of accessory information
*/
collectAccessoriesFromServer(server, bridgeUsername, bridgeType, bridgeName) {
const cached = server.getAllCachedAccessories();
const accessories = [];
// Fabric/commissioning state is server-wide — read it once, then share
// the snapshot across every accessory transform instead of re-reading it
// (3 fabric calls deep) per cached accessory.
const snapshot = server.getCommissioningSnapshot();
for (const acc of cached) {
const accessory = this.transformAccessoryData(acc, server, bridgeUsername, bridgeType, bridgeName, snapshot);
accessories.push(accessory);
}
return accessories;
}
/**
* Transform accessory data for UI consumption
*
* @param acc - Cached accessory data
* @param server - Matter server instance
* @param bridgeUsername - Bridge MAC address
* @param bridgeType - Type of bridge
* @param bridgeName - Display name of the bridge
* @returns Transformed accessory info for UI
*/
transformAccessoryData(acc, server, bridgeUsername, bridgeType, bridgeName, snapshot) {
// Get current state
const currentState = this.getCurrentStateFromServer(server, acc.uuid);
// Convert device type object to string representation
const deviceTypeStr = acc.deviceType.name || `Device Code ${acc.deviceType.code || 'unknown'}`;
return {
// Identity
uuid: acc.uuid,
displayName: acc.displayName,
serialNumber: acc.serialNumber,
manufacturer: acc.manufacturer,
model: acc.model,
firmwareRevision: acc.firmwareRevision,
// Device type
deviceType: deviceTypeStr,
// Current cluster states
clusters: currentState,
// Parts (composed devices)
parts: acc.parts?.map(part => ({
id: part.id,
displayName: part.displayName,
deviceType: part.deviceType.name || `Device Code ${part.deviceType.code || 'unknown'}`,
clusters: this.getCurrentStateFromServer(server, acc.uuid, part.id),
})),
// Bridge info
bridge: {
username: bridgeUsername,
type: bridgeType,
name: bridgeName,
},
// Plugin info
plugin: acc.plugin,
platform: acc.platform,
// Context (plugin-specific data)
context: acc.context,
// Commissioning info (if available) — sourced from a single snapshot
// built once per server in the caller, not per-accessory.
commissioned: snapshot.commissioned,
fabricCount: snapshot.fabricCount,
// Map fabric info from Matter.js format to our interface
fabrics: snapshot.fabrics.map(fabric => ({
fabricIndex: fabric.fabricIndex,
fabricId: BigInt(fabric.fabricId),
nodeId: BigInt(fabric.nodeId),
vendorId: fabric.rootVendorId, // Matter.js uses rootVendorId
label: fabric.label,
})),
};
}
/**
* Get detailed accessory info from a specific server
*
* @param server - Matter server instance
* @param uuid - Accessory UUID
* @param bridgeUsername - Bridge MAC address
* @param bridgeType - Type of bridge
* @returns Accessory info or undefined if not found
*/
getAccessoryDetailFromServer(server, uuid, bridgeUsername, bridgeType) {
const accessory = server.getAccessory(uuid);
if (!accessory) {
return undefined;
}
const cached = server.getCachedAccessory(uuid);
if (!cached) {
return undefined;
}
return this.transformAccessoryData(cached, server, bridgeUsername, bridgeType, server.bridgeName || 'Matter Bridge', server.getCommissioningSnapshot());
}
/**
* Get current state from Matter server for an accessory
*/
getCurrentStateFromServer(server, uuid, partId) {
const accessory = server.getAccessory(uuid);
if (!accessory) {
return {};
}
const endpoint = partId
? accessory._parts?.find((p) => p.id === partId)?.endpoint
: accessory.endpoint;
if (!endpoint) {
return {};
}
const state = {};
for (const [clusterName, clusterState] of Object.entries(endpoint.state)) {
state[clusterName] = {};
for (const [key, value] of Object.entries(clusterState)) {
if (!key.startsWith('_') && !key.startsWith('$')) {
state[clusterName][key] = value;
}
}
}
return state;
}
/**
* Teardown Matter servers
*/
async teardown() {
// Remove API event listeners to prevent retention of this manager after teardown
this.api.removeListener("publishExternalMatterAccessories" /* InternalAPIEvent.PUBLISH_EXTERNAL_MATTER_ACCESSORIES */, this._onPublishExternalMatterAccessories);
this.api.removeListener("registerMatterPlatformAccessories" /* InternalAPIEvent.REGISTER_MATTER_PLATFORM_ACCESSORIES */, this._onRegisterMatterPlatformAccessories);
this.api.removeListener("updateMatterPlatformAccessories" /* InternalAPIEvent.UPDATE_MATTER_PLATFORM_ACCESSORIES */, this._onUpdateMatterPlatformAccessories);
this.api.removeListener("unregisterMatterPlatformAccessories" /* InternalAPIEvent.UNREGISTER_MATTER_PLATFORM_ACCESSORIES */, this._onUnregisterMatterPlatformAccessories);
this.api.removeListener("unregisterExternalMatterAccessories" /* InternalAPIEvent.UNREGISTER_EXTERNAL_MATTER_ACCESSORIES */, this._onUnregisterExternalMatterAccessories);
this.api.removeListener("updateMatterAccessoryState" /* InternalAPIEvent.UPDATE_MATTER_ACCESSORY_STATE */, this._onUpdateMatterAccessoryState);
// Stop main Matter server if running
if (this.matterServer) {
try {
this.matterServer.removeListener('stateChange', this._onMatterServerStateChange);
await this.matterServer.stop();
}
catch (error) {
log.error('Failed to stop Matter server:', error);
}
}
// Stop all external Matter servers
for (const [uuid, matterServer] of this.externalMatterServers) {
try {
matterServer.removeListener('stateChange', this._onMatterServerStateChange);
await matterServer.stop();
log.debug(`Stopped external Matter server for ${uuid}`);
}
catch (error) {
log.error(`Failed to stop external Matter server for ${uuid}:`, error);
}
}
this.externalMatterServers.clear();
// Child bridge Matter servers are stopped by their own forked processes
}
}
//# sourceMappingURL=MatterBridgeManager.js.map