UNPKG

homebridge

Version:
505 lines 21 kB
/** * 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