homebridge
Version:
HomeKit support for the impatient
505 lines • 21 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 SmokeCoAlarm features from accessory attributes.
* The Matter spec requires at least one of SmokeAlarm/CoAlarm, so an accessory
* that declares neither state attribute falls back to SmokeAlarm — matching the
* friendly device type name "SmokeSensor".
*/
export function detectSmokeCoAlarmFeatures(accessory) {
const scaCluster = accessory.clusters?.smokeCoAlarm;
const features = [];
if (scaCluster && 'smokeState' in scaCluster) {
features.push('SmokeAlarm');
}
if (scaCluster && 'coState' in scaCluster) {
features.push('CoAlarm');
}
if (features.length === 0) {
features.push('SmokeAlarm');
}
return features;
}
/**
* Apply SmokeCoAlarm features to device type.
* SmokeCoAlarm is not part of the base SmokeCoAlarmDevice — matter.js requires
* the features to be chosen — so without this the endpoint would be created
* without the cluster and the accessory's smokeCoAlarm state silently dropped.
*/
export function applySmokeCoAlarmFeatures(deviceType, accessory, features) {
log.info(`Auto-detected SmokeCoAlarm features for ${accessory.displayName}: ${features.join(', ')}`);
const smokeCoAlarmWithFeatures = devices.SmokeCoAlarmRequirements.SmokeCoAlarmServer.with(...features);
return deviceType.with(smokeCoAlarmWithFeatures);
}
/**
* 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;
}
/**
* Matter MeasurementType enum values (Matter spec, MeasurementTypeEnum) used
* when synthesizing accuracy entries. Defined locally to avoid importing the
* full @matter/main/types barrel.
*/
const MEASUREMENT_TYPE = {
Voltage: 1,
ActiveCurrent: 2,
ActivePower: 5,
ElectricalEnergy: 14,
};
/**
* Detect electrical measurement clusters from the accessory's declared state.
*
* ElectricalEnergyMeasurement is feature-gated in matter.js (Imported/Exported
* x Cumulative/Periodic), so the features are chosen from which energy
* attributes the accessory declares.
*/
export function detectElectricalMeasurementClusters(accessory) {
const epmCluster = accessory.clusters?.electricalPowerMeasurement;
const eemCluster = accessory.clusters?.electricalEnergyMeasurement;
const energyFeatures = [];
if (eemCluster) {
if ('cumulativeEnergyImported' in eemCluster || 'periodicEnergyImported' in eemCluster) {
energyFeatures.push('ImportedEnergy');
}
if ('cumulativeEnergyExported' in eemCluster || 'periodicEnergyExported' in eemCluster) {
energyFeatures.push('ExportedEnergy');
}
if ('cumulativeEnergyImported' in eemCluster || 'cumulativeEnergyExported' in eemCluster) {
energyFeatures.push('CumulativeEnergy');
}
if ('periodicEnergyImported' in eemCluster || 'periodicEnergyExported' in eemCluster) {
energyFeatures.push('PeriodicEnergy');
}
if (energyFeatures.length === 0) {
// Cluster declared but no recognizable energy attribute yet - assume the
// most common shape (a meter reporting total imported energy).
energyFeatures.push('ImportedEnergy', 'CumulativeEnergy');
}
}
return {
hasPowerMeasurement: epmCluster !== undefined,
energyFeatures,
};
}
/**
* Build one synthesized MeasurementAccuracyStruct. The bounds are generous
* defaults - accuracy is informational metadata for controllers, and plugins
* can declare their own `accuracy` to override the synthesized list.
*/
function makeAccuracyEntry(measurementType, min, max, fixedMax) {
return {
measurementType,
measured: true,
minMeasuredValue: min,
maxMeasuredValue: max,
accuracyRanges: [{ rangeMin: min, rangeMax: max, fixedMax }],
};
}
/**
* Fill in the mandatory ElectricalPowerMeasurement / ElectricalEnergyMeasurement
* attributes (powerMode, numberOfMeasurementTypes, accuracy) that plugins should
* not have to write themselves. Mutates the accessory's declared cluster state
* so the values flow into the endpoint options and the state cache together.
*/
export function applyElectricalMeasurementDefaults(accessory, detection) {
if (detection.hasPowerMeasurement) {
const epm = accessory.clusters.electricalPowerMeasurement;
// 2 = AC (PowerModeEnum). Mains-powered metering is by far the common case.
epm.powerMode = epm.powerMode ?? 2;
// ActivePower is mandatory (nullable) - null means "no measurement yet".
if (!('activePower' in epm)) {
epm.activePower = null;
}
if (!epm.accuracy) {
const accuracy = [
// +/-100 kW range, +/-1 W accuracy
makeAccuracyEntry(MEASUREMENT_TYPE.ActivePower, -100_000_000, 100_000_000, 1000),
];
if ('voltage' in epm) {
// 0-500 V range, +/-1 V accuracy
accuracy.push(makeAccuracyEntry(MEASUREMENT_TYPE.Voltage, 0, 500_000, 1000));
}
if ('activeCurrent' in epm) {
// +/-500 A range, +/-0.1 A accuracy
accuracy.push(makeAccuracyEntry(MEASUREMENT_TYPE.ActiveCurrent, -500_000, 500_000, 100));
}
epm.accuracy = accuracy;
}
epm.numberOfMeasurementTypes = epm.numberOfMeasurementTypes
?? epm.accuracy.length;
}
if (detection.energyFeatures.length > 0) {
const eem = accessory.clusters.electricalEnergyMeasurement;
if (!eem.accuracy) {
// 0 - 1e15 mWh (a billion kWh), +/-1 Wh accuracy
eem.accuracy = makeAccuracyEntry(MEASUREMENT_TYPE.ElectricalEnergy, 0, 1_000_000_000_000_000, 1000);
}
}
}
/**
* Apply the electrical measurement behaviors to a device type.
*
* PowerTopology is mandatory on the ElectricalSensor device type and is
* feature-gated in matter.js; TreeTopology fits a bridged endpoint that
* measures itself. The EEM server keeps matter.js's setMeasurement() helper,
* which also emits the CumulativeEnergyMeasured / PeriodicEnergyMeasured
* events required by the spec.
*/
export function applyElectricalMeasurementClusters(deviceType, accessory, detection) {
if (!detection.hasPowerMeasurement && detection.energyFeatures.length === 0) {
return deviceType;
}
const requirements = devices.ElectricalSensorRequirements;
const behaviors = [];
const existing = deviceType.behaviors ?? {};
if (!existing.powerTopology) {
behaviors.push(requirements.PowerTopologyServer.with('TreeTopology'));
}
if (detection.hasPowerMeasurement && !existing.electricalPowerMeasurement) {
behaviors.push(requirements.ElectricalPowerMeasurementServer);
}
if (detection.energyFeatures.length > 0 && !existing.electricalEnergyMeasurement) {
behaviors.push(requirements.ElectricalEnergyMeasurementServer.with(...detection.energyFeatures));
}
if (behaviors.length === 0) {
return deviceType;
}
const detected = [
detection.hasPowerMeasurement ? 'power' : undefined,
detection.energyFeatures.length > 0 ? `energy (${detection.energyFeatures.join(', ')})` : undefined,
].filter(Boolean).join(' + ');
log.info(`Auto-detected electrical measurement for ${accessory.displayName}: ${detected}`);
return deviceType.with(...behaviors);
}
//# sourceMappingURL=serverHelpers.js.map