homebridge
Version:
HomeKit support for the impatient
338 lines • 13.7 kB
JavaScript
/**
* Helper functions for MatterServer.registerAccessory()
* Extracted from the monolithic 521-line function for better maintainability
*/
import { Logger } from '../logger.js';
import { HomebridgeRvcCleanModeServer, HomebridgeServiceAreaServer, HomebridgeWindowCoveringServer, } from './behaviors/index.js';
// Direct matter.js .with() API used instead of typeHelpers wrappers
import { clusters, devices, MatterDeviceError } from './types.js';
const log = Logger.withPrefix('Matter/Server');
/**
* Cluster IDs from Matter specification
* Using Matter.js Cluster references instead of magic numbers
*/
export const CLUSTER_IDS = {
AIR_QUALITY: clusters.AirQuality.Cluster.id,
CARBON_MONOXIDE_CONCENTRATION: clusters.CarbonMonoxideConcentrationMeasurement.Cluster.id,
COLOR_CONTROL: clusters.ColorControl.Cluster.id,
DOOR_LOCK: clusters.DoorLock.Cluster.id,
LEVEL_CONTROL: clusters.LevelControl.Cluster.id,
NITROGEN_DIOXIDE_CONCENTRATION: clusters.NitrogenDioxideConcentrationMeasurement.Cluster.id,
ON_OFF: clusters.OnOff.Cluster.id,
OZONE_CONCENTRATION: clusters.OzoneConcentrationMeasurement.Cluster.id,
PM10_CONCENTRATION: clusters.Pm10ConcentrationMeasurement.Cluster.id,
PM25_CONCENTRATION: clusters.Pm25ConcentrationMeasurement.Cluster.id,
THERMOSTAT: clusters.Thermostat.Cluster.id,
WINDOW_COVERING: clusters.WindowCovering.Cluster.id,
};
/**
* Validates required fields on a Matter accessory
* @throws MatterDeviceError if validation fails
*/
export function validateAccessoryRequiredFields(accessory) {
if (!accessory.deviceType) {
throw new MatterDeviceError(`Matter accessory "${accessory.displayName || 'unknown'}" is missing required field 'deviceType'. `
+ 'Example: deviceType: api.matter!.deviceTypes.OnOffLight\n'
+ 'Available device types: OnOffLight, DimmableLight, GenericSwitch, TemperatureSensor, etc.\n'
+ 'See the Matter types documentation for the full list.');
}
if (!accessory.UUID) {
throw new MatterDeviceError('Matter accessory is missing required field \'UUID\'.\n'
+ 'Generate a unique UUID for your accessory:\n'
+ ' const UUID = api.hap.uuid.generate(\'my-unique-id\')');
}
if (!accessory.displayName) {
throw new MatterDeviceError(`Matter accessory (${accessory.UUID}) is missing required field 'displayName'.\n`
+ 'Example: displayName: \'Living Room Light\'');
}
if (!accessory.serialNumber) {
throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'serialNumber'.\n`
+ 'Example: serialNumber: \'ABC123\' or serialNumber: accessory.UUID');
}
if (!accessory.manufacturer) {
throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'manufacturer'.\n`
+ 'Example: manufacturer: \'Homebridge\' or manufacturer: \'My Plugin Name\'');
}
if (!accessory.model) {
throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing required field 'model'.\n`
+ 'Example: model: \'v1.0\' or model: \'Smart Light\'');
}
// Clusters are required unless parts are provided (for composed devices)
if (!accessory.parts || accessory.parts.length === 0) {
if (!accessory.clusters || typeof accessory.clusters !== 'object') {
throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" is missing or has invalid 'clusters' field.\n`
+ 'Clusters define the functionality of your device. Example:\n'
+ ' clusters: {\n'
+ ' onOff: { onOff: false },\n'
+ ' levelControl: { currentLevel: 0, minLevel: 0, maxLevel: 254 }\n'
+ ' }\n'
+ 'Alternatively, use "parts" array for composed devices with multiple endpoints.');
}
}
// Validate parts if provided
if (accessory.parts && accessory.parts.length > 0) {
for (const part of accessory.parts) {
if (!part.id) {
throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" has a part missing required field 'id'`);
}
if (!part.deviceType) {
throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" part "${part.id}" is missing required field 'deviceType'`);
}
if (!part.clusters || typeof part.clusters !== 'object') {
throw new MatterDeviceError(`Matter accessory "${accessory.displayName}" part "${part.id}" is missing or has invalid 'clusters' field`);
}
}
}
}
/**
* Convert device type behaviors to array
* Handles array, Set, object, or iterable formats
*/
function convertBehaviorsToArray(behaviors) {
if (Array.isArray(behaviors)) {
return behaviors;
}
if (typeof behaviors === 'object' && behaviors !== null) {
const values = Object.values(behaviors);
if (values.length > 0) {
return values;
}
}
try {
return [...behaviors];
}
catch {
return [];
}
}
/**
* Find a specific behavior by cluster ID or name
*/
function findBehaviorByCluster(behaviors, clusterIdOrName) {
return behaviors.find((behavior) => {
if (typeof clusterIdOrName === 'number') {
return behavior.cluster?.id === clusterIdOrName;
}
return behavior.id === clusterIdOrName;
});
}
/**
* Generic feature detection from device type behaviors
* Extracts supported features from a device type's cluster definition
*
* @param deviceType - The Matter device type
* @param clusterIdOrName - Cluster ID (number) or name (string)
* @param featureExtractor - Function to extract feature names from supportedFeatures
* @returns Array of detected features or null if cluster not found
*/
export function detectBehaviorFeatures(deviceType, clusterIdOrName, featureExtractor) {
const deviceTypeDef = deviceType;
const existingBehaviors = deviceTypeDef.behaviors;
if (!existingBehaviors) {
return null;
}
const behaviorsArray = convertBehaviorsToArray(existingBehaviors);
const behavior = findBehaviorByCluster(behaviorsArray, clusterIdOrName);
if (!behavior?.cluster?.supportedFeatures) {
return null;
}
return featureExtractor(behavior.cluster.supportedFeatures);
}
/**
* Extract ColorControl features from supportedFeatures
*/
export function extractColorControlFeatures(supportedFeatures) {
const features = [];
if (supportedFeatures.hueSaturation) {
features.push('HueSaturation');
}
if (supportedFeatures.xy) {
features.push('Xy');
}
if (supportedFeatures.colorTemperature) {
features.push('ColorTemperature');
}
return features;
}
/**
* Extract Thermostat features from supportedFeatures
*/
export function extractThermostatFeatures(supportedFeatures) {
const features = [];
if (supportedFeatures.heating) {
features.push('Heating');
}
if (supportedFeatures.cooling) {
features.push('Cooling');
}
if (supportedFeatures.occupancy) {
features.push('Occupancy');
}
if (supportedFeatures.autoMode) {
features.push('AutoMode');
}
return features;
}
/**
* Extract LevelControl features from supportedFeatures.
*
* Used to read features off a device type's declared LevelControl requirement
* (e.g. DimmableLightDevice's `LevelControlServer.with("Lighting","OnOff")`).
* When the device type doesn't declare LevelControl at all (e.g. PumpDevice,
* which has LevelControl only in its `optional` requirements and not in
* `SupportedBehaviors`), the caller should apply an empty feature set via
* `.with()` so the Lighting feature inherited from matter.js's internal
* `LevelControlBase = LevelControlBehavior.with(OnOff, Lighting)` is stripped
* — otherwise the Pump endpoint inherits the `[LT]` branch of the spec
* (minLevel constraint 1-254, initializeLighting warnings) that only applies
* to lighting devices.
*/
export function extractLevelControlFeatures(supportedFeatures) {
const features = [];
if (supportedFeatures.onOff) {
features.push('OnOff');
}
if (supportedFeatures.lighting) {
features.push('Lighting');
}
if (supportedFeatures.frequency) {
features.push('Frequency');
}
return features;
}
/**
* Determine ColorControl features based on handlers
* Only includes features that have corresponding handler methods
*/
export function determineColorControlFeaturesFromHandlers(handlers) {
const features = [];
if ('moveToHueAndSaturationLogic' in handlers) {
features.push('HueSaturation');
}
if ('moveToColorLogic' in handlers) {
features.push('Xy');
}
if ('moveToColorTemperatureLogic' in handlers) {
features.push('ColorTemperature');
}
return features;
}
/**
* Detect WindowCovering features from accessory attributes
* Auto-detects Lift and Tilt capabilities based on cluster attributes
*
* @param accessory - Matter accessory to inspect
* @returns Array of detected feature names
*/
export function detectWindowCoveringFeatures(accessory) {
const features = [];
const wcCluster = accessory.clusters?.windowCovering;
if (!wcCluster) {
return features;
}
// Detect lift capability
const hasLiftAttrs = 'targetPositionLiftPercent100ths' in wcCluster
|| 'currentPositionLiftPercent100ths' in wcCluster;
const configStatus = wcCluster.configStatus;
const hasConfigLift = configStatus?.liftPositionAware === true;
// Detect tilt capability
const hasTiltAttrs = 'targetPositionTiltPercent100ths' in wcCluster
|| 'currentPositionTiltPercent100ths' in wcCluster;
const hasConfigTilt = configStatus?.tiltPositionAware === true;
log.debug(`[${accessory.displayName}] WindowCovering detection: `
+ `hasLiftAttrs=${hasLiftAttrs}, hasConfigLift=${hasConfigLift}, `
+ `hasTiltAttrs=${hasTiltAttrs}, hasConfigTilt=${hasConfigTilt}`);
if (hasLiftAttrs) {
features.push('Lift');
if (hasConfigLift) {
features.push('PositionAwareLift');
}
}
if (hasTiltAttrs) {
features.push('Tilt');
if (hasConfigTilt) {
features.push('PositionAwareTilt');
}
}
return features;
}
/**
* Detect ServiceArea features from cluster attributes
*/
export function detectServiceAreaFeatures(serviceAreaCluster) {
const features = [];
if (!serviceAreaCluster) {
return features;
}
if ('supportedMaps' in serviceAreaCluster) {
features.push('Maps');
}
if ('progress' in serviceAreaCluster) {
features.push('ProgressReporting');
}
return features;
}
/**
* Apply WindowCovering features to device type
*/
export function applyWindowCoveringFeatures(deviceType, accessory, features) {
if (features.length === 0) {
log.warn(`⚠️ No WindowCovering features detected for ${accessory.displayName}!`);
return deviceType;
}
log.info(`Auto-detected WindowCovering features for ${accessory.displayName}: ${features.join(', ')}`);
// Add WindowCoveringServer with features to the device type
const windowCoveringWithFeatures = HomebridgeWindowCoveringServer.with(...features);
const modifiedDeviceType = deviceType.with(windowCoveringWithFeatures);
const hasTiltFeatures = features.includes('Tilt');
if (hasTiltFeatures && accessory.clusters) {
const wcCluster = accessory.clusters.windowCovering;
wcCluster.type = 8; // TiltBlindLift
log.debug('Set WindowCovering type to 8 (TiltBlindLift) for tilt-capable device');
}
if (!accessory.context) {
accessory.context = {};
}
accessory.context._skipWindowCoveringBehavior = true;
return modifiedDeviceType;
}
/**
* Build custom behaviors for RoboticVacuumCleaner devices
*/
export function buildRvcCustomBehaviors(accessory, serviceAreaFeatures) {
const customBehaviors = [];
const { RvcCleanModeServer, ServiceAreaServer } = devices.RoboticVacuumCleanerRequirements;
if (accessory.clusters?.rvcCleanMode) {
if (accessory.handlers?.rvcCleanMode) {
customBehaviors.push(HomebridgeRvcCleanModeServer);
log.info('Adding custom RvcCleanMode behavior with handlers');
}
else {
customBehaviors.push(RvcCleanModeServer);
log.info('Adding base RvcCleanMode server');
}
}
if (accessory.clusters?.serviceArea) {
let behaviorClass = accessory.handlers?.serviceArea
? HomebridgeServiceAreaServer
: ServiceAreaServer;
if (serviceAreaFeatures && serviceAreaFeatures.length > 0) {
behaviorClass = behaviorClass.with(...serviceAreaFeatures);
log.info(`ServiceArea ${accessory.handlers?.serviceArea ? 'custom behavior' : 'base server'} will have features: ${serviceAreaFeatures.join(', ')}`);
}
customBehaviors.push(behaviorClass);
}
return customBehaviors;
}
/**
* Apply detected features to a behavior class
*/
export function applyFeaturesToBehavior(behaviorClass, features, clusterName) {
if (!features || features.length === 0) {
return behaviorClass;
}
const modifiedBehavior = behaviorClass.with(...features);
log.info(`${clusterName} custom behavior will preserve features: ${features.join(', ')}`);
return modifiedBehavior;
}
//# sourceMappingURL=serverHelpers.js.map