homebridge
Version:
HomeKit support for the impatient
336 lines • 15.7 kB
JavaScript
/**
* Matter API Implementation
*
* Implements the Matter API facade with lazy loading to optimize performance.
*
* Architecture:
* - Separates Matter-specific logic from core HomebridgeAPI class
* - Uses dynamic imports to prevent loading Matter.js at module parse time
* - Loads Matter types on first access to `api.matter` properties
* - Child bridges that don't use Matter have zero Matter.js overhead
*
* Performance Impact:
* - Before: Every child bridge loaded ~800ms of Matter.js code (8-16s on RPi)
* - After: Only child bridges using Matter load it on first access
* - Improvement: 75-90% reduction in startup time for multi-bridge setups
*/
import { Logger } from '../logger.js';
import { clusterNames, clusters, deviceTypes, MatterTypes } from './index.js';
import { SwitchAPIImpl } from './SwitchAPI.js';
const log = Logger.withPrefix('Matter/API');
// ============================================================================
// External Device Type Configuration
// ============================================================================
/**
* Device types that require dedicated external bridges.
*
* Some Matter devices (like RoboticVacuumCleaner) are complex and must be
* published on their own dedicated bridge, not added to the main/child bridge.
*/
const EXTERNAL_DEVICE_TYPES = [
deviceTypes.RoboticVacuumCleaner,
];
/**
* Check if a device type requires external bridge publishing.
* Compares device type IDs for exact match against the external device types list.
*/
function requiresExternalBridge(deviceType) {
return EXTERNAL_DEVICE_TYPES.some(externalType => externalType.deviceType === deviceType.deviceType);
}
// ============================================================================
/**
* Validation error for Matter accessories
*/
class MatterAccessoryValidationError extends Error {
accessory;
constructor(message, accessory) {
super(message);
this.accessory = accessory;
this.name = 'MatterAccessoryValidationError';
}
}
/**
* Implementation of the Matter API
*
* This facade provides Matter protocol support through the Homebridge API.
* It uses lazy loading to prevent loading the heavy Matter.js library until
* actually needed, improving startup performance for child bridges that don't
* use Matter.
*
* Features:
* - Lazy-loads Matter types on first access
* - Validates accessories before registration
* - Handles both bridge accessories and external standalone devices
* - Provides detailed error messages for debugging
* - Delegates to HomebridgeAPI for event emission and server access
*/
export class MatterAPIImpl {
api;
switch;
constructor(api) {
this.api = api;
this.switch = new SwitchAPIImpl(this);
}
/**
* Validate a Matter accessory has required fields
* @throws MatterAccessoryValidationError if validation fails
*/
validateAccessory(accessory, context) {
if (!accessory.UUID) {
throw new MatterAccessoryValidationError(`${context}: Matter accessory missing required 'UUID' field`, accessory);
}
if (!accessory.displayName) {
throw new MatterAccessoryValidationError(`${context}: Matter accessory '${accessory.UUID}' missing required 'displayName' field`, accessory);
}
if (!accessory.deviceType) {
throw new MatterAccessoryValidationError(`${context}: Matter accessory '${accessory.displayName}' (${accessory.UUID}) missing required 'deviceType' field`, accessory);
}
if (!accessory.manufacturer) {
throw new MatterAccessoryValidationError(`${context}: Matter accessory '${accessory.displayName}' (${accessory.UUID}) missing required 'manufacturer' field`, accessory);
}
if (!accessory.model) {
throw new MatterAccessoryValidationError(`${context}: Matter accessory '${accessory.displayName}' (${accessory.UUID}) missing required 'model' field`, accessory);
}
if (!accessory.serialNumber) {
throw new MatterAccessoryValidationError(`${context}: Matter accessory '${accessory.displayName}' (${accessory.UUID}) missing required 'serialNumber' field`, accessory);
}
}
/**
* Validate an array of accessories, logging errors for invalid ones
* @returns Array of valid accessories only
*/
validateAccessories(accessories, context) {
const validAccessories = [];
for (const accessory of accessories) {
try {
this.validateAccessory(accessory, context);
validAccessories.push(accessory);
}
catch (error) {
if (error instanceof MatterAccessoryValidationError) {
log.error(error.message);
log.error('This accessory will not be registered. Please fix the issue in your plugin.');
}
else {
log.error(`${context}: Unexpected error validating accessory:`, error);
}
}
}
return validAccessories;
}
/**
* Validate cluster name is valid
*
* @param clusterName - Cluster name to validate
* @param context - Context string for error messages
*/
validateClusterName(clusterName, context) {
// Check if cluster name is in the known cluster names
const validClusterNames = Object.values(clusterNames);
if (!validClusterNames.includes(clusterName)) {
log.warn(`${context}: Unknown cluster name '${clusterName}'. This might cause issues. `
+ `Valid clusters: ${validClusterNames.join(', ')}`);
}
}
/**
* UUID generator (alias of api.hap.uuid for convenience)
*/
get uuid() {
return this.api.hap.uuid;
}
/**
* Matter device types for creating accessories
*/
get deviceTypes() {
return deviceTypes;
}
/**
* Matter clusters - Direct access to Matter.js cluster definitions
*/
get clusters() {
return clusters;
}
/**
* Matter cluster names for type safety and autocomplete
*/
get clusterNames() {
return clusterNames;
}
/**
* Matter types - Access to Matter.js cluster type definitions and enums
*/
get types() {
return MatterTypes;
}
/**
* Register Matter platform accessories
* Automatically handles external accessories (e.g., RoboticVacuumCleaner) that need dedicated bridges
* Validates accessories before registration
* Returns a promise that resolves when all accessories are fully registered
*/
async registerPlatformAccessories(pluginIdentifier, platformName, accessories) {
if (accessories.length === 0) {
log.warn(`${pluginIdentifier}: Attempted to register 0 Matter accessories`);
return;
}
// Validate all accessories before registration
const validAccessories = this.validateAccessories(accessories, `registerPlatformAccessories (${pluginIdentifier}/${platformName})`);
if (validAccessories.length === 0) {
log.error(`${pluginIdentifier}: All ${accessories.length} Matter accessories failed validation`);
return;
}
if (validAccessories.length < accessories.length) {
log.warn(`${pluginIdentifier}: ${accessories.length - validAccessories.length} of ${accessories.length} Matter accessories failed validation`);
}
// Split accessories into normal (bridge) and external (standalone) based on device type
const normalAccessories = [];
const externalAccessories = [];
for (const accessory of validAccessories) {
if (requiresExternalBridge(accessory.deviceType)) {
externalAccessories.push(accessory);
}
else {
normalAccessories.push(accessory);
}
}
// Handle normal accessories (added to bridge)
if (normalAccessories.length > 0) {
// Add plugin/platform association
normalAccessories.forEach((accessory) => {
const internal = accessory;
internal._associatedPlugin = pluginIdentifier;
internal._associatedPlatform = platformName;
});
log.debug(`${pluginIdentifier}: Registering ${normalAccessories.length} Matter accessor${normalAccessories.length === 1 ? 'y' : 'ies'} for platform '${platformName}'`);
this.api.emit("registerMatterPlatformAccessories" /* InternalAPIEvent.REGISTER_MATTER_PLATFORM_ACCESSORIES */, pluginIdentifier, platformName, normalAccessories);
}
// Handle external accessories (standalone bridges)
if (externalAccessories.length > 0) {
// Add plugin association (no platform for external)
externalAccessories.forEach((accessory) => {
const internal = accessory;
internal._associatedPlugin = pluginIdentifier;
});
log.debug(`${pluginIdentifier}: Publishing ${externalAccessories.length} external Matter accessor${externalAccessories.length === 1 ? 'y' : 'ies'} (${externalAccessories.map(a => a.displayName).join(', ')})`);
// Create a promise to track when external publishing completes
const registrationId = `${pluginIdentifier}-${Date.now()}-${Math.random()}`;
const registrationPromise = new Promise((resolve) => {
// Store the resolve function so it can be called when publishing completes
// Access internal properties through type assertion
const internalApi = this.api;
if (!internalApi._pendingExternalRegistrations) {
internalApi._pendingExternalRegistrations = new Map();
}
internalApi._pendingExternalRegistrations.set(registrationId, resolve);
});
// Emit event with registration ID
this.api.emit("publishExternalMatterAccessories" /* InternalAPIEvent.PUBLISH_EXTERNAL_MATTER_ACCESSORIES */, externalAccessories, registrationId);
// Wait for external publishing to complete
await registrationPromise;
}
}
/**
* Update Matter platform accessories in the cache
* Similar to api.updatePlatformAccessories() for HAP accessories
*/
async updatePlatformAccessories(accessories) {
if (accessories.length === 0) {
log.warn('Attempted to update 0 Matter platform accessories');
return;
}
log.debug(`Updating ${accessories.length} Matter platform accessor${accessories.length === 1 ? 'y' : 'ies'} in cache`);
// Emit event for Server/ChildBridgeFork to handle
this.api.emit("updateMatterPlatformAccessories" /* InternalAPIEvent.UPDATE_MATTER_PLATFORM_ACCESSORIES */, accessories);
}
/**
* Unregister Matter platform accessories
* Automatically handles external accessories (e.g., RoboticVacuumCleaner) that have dedicated bridges
*/
async unregisterPlatformAccessories(pluginIdentifier, platformName, accessories) {
if (accessories.length === 0) {
log.warn(`${pluginIdentifier}: Attempted to unregister 0 Matter accessories`);
return;
}
// Split accessories into normal (bridge) and external (standalone) based on device type
const normalAccessories = [];
const externalAccessories = [];
for (const accessory of accessories) {
if (requiresExternalBridge(accessory.deviceType)) {
externalAccessories.push(accessory);
}
else {
normalAccessories.push(accessory);
}
}
// Handle normal accessories (on bridge)
if (normalAccessories.length > 0) {
log.debug(`${pluginIdentifier}: Unregistering ${normalAccessories.length} Matter accessor${normalAccessories.length === 1 ? 'y' : 'ies'} from platform '${platformName}'`);
this.api.emit("unregisterMatterPlatformAccessories" /* InternalAPIEvent.UNREGISTER_MATTER_PLATFORM_ACCESSORIES */, pluginIdentifier, platformName, normalAccessories);
}
// Handle external accessories (standalone bridges)
if (externalAccessories.length > 0) {
log.debug(`${pluginIdentifier}: Unregistering ${externalAccessories.length} external Matter accessor${externalAccessories.length === 1 ? 'y' : 'ies'} (${externalAccessories.map(a => a.displayName).join(', ')})`);
this.api.emit("unregisterExternalMatterAccessories" /* InternalAPIEvent.UNREGISTER_EXTERNAL_MATTER_ACCESSORIES */, externalAccessories);
}
}
/**
* Update a Matter accessory's cluster state
* Validates inputs before updating
*/
async updateAccessoryState(uuid, cluster, attributes, partId) {
// Validate inputs
if (!uuid) {
log.error('updateAccessoryState: uuid parameter is required');
return;
}
if (!cluster) {
log.error(`updateAccessoryState: cluster parameter is required for accessory ${uuid}`);
return;
}
if (!attributes || Object.keys(attributes).length === 0) {
log.warn(`updateAccessoryState: No attributes provided for accessory ${uuid}, cluster ${cluster}`);
return;
}
// Validate cluster name (warning only, don't block)
this.validateClusterName(cluster, `updateAccessoryState (${uuid})`);
log.debug(`Updating Matter accessory state: uuid=${uuid}, cluster=${cluster}, attributes=${Object.keys(attributes).join(', ')}${partId ? `, partId=${partId}` : ''}`);
// Emit the event (listeners will be called synchronously by EventEmitter)
this.api.emit("updateMatterAccessoryState" /* InternalAPIEvent.UPDATE_MATTER_ACCESSORY_STATE */, uuid, cluster, attributes, partId);
}
/**
* Get a Matter accessory's current cluster state
* Checks both external servers and main bridge server
* Validates inputs before retrieving state
*/
async getAccessoryState(uuid, cluster, partId) {
// Validate inputs
if (!uuid) {
log.error('getAccessoryState: uuid parameter is required');
return undefined;
}
if (!cluster) {
log.error(`getAccessoryState: cluster parameter is required for accessory ${uuid}`);
return undefined;
}
// Validate cluster name (warning only, don't block)
this.validateClusterName(cluster, `getAccessoryState (${uuid})`);
log.debug(`Getting Matter accessory state: uuid=${uuid}, cluster=${cluster}${partId ? `, partId=${partId}` : ''}`);
// Check external servers first (for accessories like robot vacuums)
const internalApi = this.api;
const matterManager = internalApi._matterManager;
if (matterManager) {
const externalServer = matterManager.getExternalServer(uuid);
if (externalServer) {
return externalServer.getAccessoryState(uuid, cluster, partId);
}
}
// Otherwise, try the main bridge server
const matterServer = internalApi._matterServer;
if (!matterServer) {
log.debug(`getAccessoryState: Matter server not available for accessory ${uuid}`);
return undefined;
}
return matterServer.getAccessoryState(uuid, cluster, partId);
}
}
//# sourceMappingURL=MatterAPIImpl.js.map