UNPKG

homebridge

Version:
706 lines 30.7 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, CLOSURE_CONTROL: clusters.ClosureControl.Cluster.id, COLOR_CONTROL: clusters.ColorControl.Cluster.id, DOOR_LOCK: clusters.DoorLock.Cluster.id, KEYPAD_INPUT: clusters.KeypadInput.Cluster.id, LEVEL_CONTROL: clusters.LevelControl.Cluster.id, MEDIA_PLAYBACK: clusters.MediaPlayback.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: 1, minLevel: 1, 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); // matter.js records a composed behavior's features on the behavior itself. // `cluster.supportedFeatures` is only populated for clusters that declare it, // which none of the ones we inspect here do — so relying on it alone meant // this always returned null and every caller fell back to its "no features" // path. Read the behavior's own features when the cluster does not carry them. const supportedFeatures = behavior?.cluster?.supportedFeatures ?? behavior?.features; if (!supportedFeatures) { return null; } return featureExtractor(supportedFeatures); } /** * Extract whatever features a behavior already declares, whatever the cluster. * * matter.js records a composed behavior's features as camelCase booleans and * `.with(...)` takes the PascalCase names, so carrying a feature set across is * just a capitalise - the same mapping the per-cluster extractors below do by * hand (`hueSaturation` to `HueSaturation`, `xy` to `Xy`). * * Used for clusters whose features we have no reason to reason about. We only * need them preserved when a Homebridge behavior replaces the base one, and a * hand-written list would silently drop any feature matter.js adds later. */ export function extractDeclaredFeatures(supportedFeatures) { return Object.entries(supportedFeatures) .filter(([, enabled]) => enabled) .map(([name]) => name.charAt(0).toUpperCase() + name.slice(1)); } /** * 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; } /** * Determine ColorControl features from the cluster's own attributes. * * The handler-based detection above cannot work on a cache restore: handlers are * functions, so they are not cached, and the restore synthesizes empty stubs in * their place. That left ColorControl with no features at all, and a persisted * `colorTemperatureMireds` or `currentHue` then failed Matter's conformance check * ("Matter does not allow you to set this attribute") and took the whole accessory * registration down with it. * * The attributes do survive the cache, so they are what we fall back to - the same * approach {@link detectWindowCoveringFeatures} already takes. * * @param clusters - the accessory's cluster state * @returns Array of detected feature names */ export function determineColorControlFeaturesFromClusters(clusters) { const colorControl = clusters?.colorControl; if (!colorControl) { return []; } const features = []; if ('currentHue' in colorControl || 'currentSaturation' in colorControl) { features.push('HueSaturation'); } if ('currentX' in colorControl || 'currentY' in colorControl) { features.push('Xy'); } if ('colorTemperatureMireds' in colorControl) { 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); } /** * Raise a LevelControl floor of 0 to 1 when the Lighting feature is active. * * The Lighting feature reserves level 0 for "off", so the spec constrains * MinLevel to 1-254 and matter.js rejects 0 outright. Homebridge's own guidance * has suggested `minLevel: 0`, so accessories in the wild carry it. Lift those * to 1 with a warning rather than refusing to register the accessory. */ export function applyLevelControlLightingFloor(accessory, levelControlFeatures) { if (!levelControlFeatures?.includes('Lighting')) { return; } const levelControl = accessory.clusters?.levelControl; if (!levelControl) { return; } for (const attribute of ['minLevel', 'currentLevel']) { const value = levelControl[attribute]; if (typeof value === 'number' && value < 1) { log.warn(`${accessory.displayName} declares levelControl.${attribute} of ${value}, but the Lighting ` + 'feature reserves level 0 - using 1 instead.'); levelControl[attribute] = 1; } } } /** * Detect Thermostat features from accessory attributes. * * A thermostat that only heats should not advertise cooling, so the features * follow the setpoints the accessory actually declares. The Matter spec requires * at least one of Heating/Cooling, so an accessory declaring neither falls back * to Heating. AutoMode is only meaningful when the device can do both, and * Occupancy only when the unoccupied setpoints are present. */ export function detectThermostatFeatures(accessory) { const thermostatCluster = accessory.clusters?.thermostat; const has = (attribute) => thermostatCluster !== undefined && attribute in thermostatCluster; const features = []; if (has('occupiedHeatingSetpoint') || has('unoccupiedHeatingSetpoint')) { features.push('Heating'); } if (has('occupiedCoolingSetpoint') || has('unoccupiedCoolingSetpoint')) { features.push('Cooling'); } if (features.length === 0) { features.push('Heating'); } if (features.includes('Heating') && features.includes('Cooling')) { features.push('AutoMode'); } if (has('unoccupiedHeatingSetpoint') || has('unoccupiedCoolingSetpoint')) { features.push('Occupancy'); } return features; } /** * The spec's absolute setpoint bounds, in 0.01°C. matter.js falls back to these * when an accessory declares no abs*SetpointLimit of its own. */ const THERMOSTAT_ABS_LIMITS = { Heat: { min: 700, max: 3000 }, Cool: { min: 1600, max: 3200 }, }; /** * Check that an AutoMode thermostat's setpoint LIMITS can satisfy its deadband. * * ⚠️ The deadband applies to the limits, not only to the setpoints. matter.js * requires both of these, in 0.01°C: * maxCoolSetpointLimit - maxHeatSetpointLimit >= deadband * minCoolSetpointLimit - minHeatSetpointLimit >= deadband * * `minSetpointDeadBand` is declared in 0.1°C, so it is multiplied by 10 first. * * This is easy to get wrong and the symptom is badly disconnected from the * cause: the endpoint is created happily, then EVERY later setpoint update * fails with "Thermostat setpoints could not be reconciled within the * configured limits". matter.js 0.17.6 only validated the attribute being * written, so an impossible configuration went unnoticed until 0.17.7 began * validating the whole cluster. Warning here points at the real problem. * * Returns a message describing the problem, or undefined when the limits are * satisfiable. */ export function checkThermostatSetpointLimits(accessory, features) { if (!features.includes('AutoMode')) { return undefined; } const cluster = accessory.clusters?.thermostat; if (!cluster) { return undefined; } // matter.js treats an undeclared deadband as 2.0°C, and replaces anything // above the legal 0..127 with the same value. const declared = cluster.minSetpointDeadBand; const deadBand = (declared === undefined || declared > 127 ? 20 : declared) * 10; // An unset user limit does NOT fall back to the spec default - it falls back // to the accessory's own absolute limit for that mode, and only then to the // spec default. matter.js does exactly this (`#userLimit` reads // `min|maxXSetpointLimit ?? absMin|absMaxXSetpointLimit`), so a thermostat // that widens only its absolute range would otherwise be measured against // bounds it never declared - and warned about, or cleared, wrongly. const limit = (scope, bound) => cluster[`${bound}${scope}SetpointLimit`] ?? cluster[`abs${bound === 'min' ? 'Min' : 'Max'}${scope}SetpointLimit`] ?? THERMOSTAT_ABS_LIMITS[scope][bound]; const problems = []; for (const bound of ['max', 'min']) { const cool = limit('Cool', bound); const heat = limit('Heat', bound); if (cool - heat < deadBand) { problems.push(`${bound}CoolSetpointLimit (${cool}) - ${bound}HeatSetpointLimit (${heat}) = ${cool - heat}, ` + `which is less than the ${deadBand} deadband`); } } if (problems.length === 0) { return undefined; } return `${accessory.displayName} declares a thermostat with Auto mode whose setpoint limits cannot satisfy its ` + `minSetpointDeadBand of ${deadBand / 10} (${deadBand} in setpoint units): ${problems.join('; ')}. ` + 'Every setpoint update will be rejected until this is corrected - either lower minSetpointDeadBand, ' + 'or widen the gap between the heating and cooling limits.'; } /** * Apply Thermostat features to device type. * Thermostat is not part of the base ThermostatDevice — matter.js requires the * features to be chosen — so without this the endpoint would be created without * the cluster and the accessory's thermostat state silently dropped. */ export function applyThermostatFeatures(deviceType, accessory, features) { log.info(`Auto-detected Thermostat features for ${accessory.displayName}: ${features.join(', ')}`); const limitProblem = checkThermostatSetpointLimits(accessory, features); if (limitProblem) { log.warn(limitProblem); } const thermostatWithFeatures = devices.ThermostatRequirements.ThermostatServer.with(...features); return deviceType.with(thermostatWithFeatures); } /** * 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) { // The EPM cluster's feature conformance requires at least one of // DirectCurrent/AlternatingCurrent. Follow the accessory's powerMode // (already resolved by applyElectricalMeasurementDefaults: plugin value // or the AC default): 1 = DC, everything else measures mains. const powerMode = accessory.clusters?.electricalPowerMeasurement?.powerMode; const currentFeature = powerMode === 1 ? 'DirectCurrent' : 'AlternatingCurrent'; behaviors.push(requirements.ElectricalPowerMeasurementServer.with(currentFeature)); } 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