UNPKG

zigbee-herdsman-converters

Version:

Collection of device converters to be used with zigbee-herdsman

1,140 lines (1,077 loc) • 264 kB
'use strict'; /** * Documentation of convert() parameters * - model: zigbee-herdsman-converters definition (form definition) * - msg: message data property * - publish: publish method * - options: converter options object, e.g. {occupancy_timeout: 120} * - meta: object containing {device: (zigbee-herdsman device object)} */ const { precisionRound, mapNumberRange, isLegacyEnabled, toLocalISOString, numberWithinRange, hasAlreadyProcessedMessage, calibrateAndPrecisionRoundOptions, addActionGroup, postfixWithEndpointName, getKey, batteryVoltageToPercentage, getMetaValue, } = require('../lib/utils'); const tuya = require('../lib/tuya'); const globalStore = require('../lib/store'); const constants = require('../lib/constants'); const libColor = require('../lib/color'); const utils = require('../lib/utils'); const converters = { // #region Generic/recommended converters fan: { cluster: 'hvacFanCtrl', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const key = getKey(constants.fanMode, msg.data.fanMode); return {fan_mode: key, fan_state: key === 'off' ? 'OFF' : 'ON'}; }, }, thermostat: { cluster: 'hvacThermostat', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const result = {}; if (msg.data.hasOwnProperty('localTemp')) { result[postfixWithEndpointName('local_temperature', msg, model)] = precisionRound(msg.data['localTemp'], 2) / 100; } if (msg.data.hasOwnProperty('localTemperatureCalibration')) { result[postfixWithEndpointName('local_temperature_calibration', msg, model)] = precisionRound(msg.data['localTemperatureCalibration'], 2) / 10; } if (msg.data.hasOwnProperty('occupancy')) { result[postfixWithEndpointName('occupancy', msg, model)] = (msg.data.occupancy % 2) > 0; } if (msg.data.hasOwnProperty('occupiedHeatingSetpoint')) { let value = precisionRound(msg.data['occupiedHeatingSetpoint'], 2) / 100; // Stelpro will return -325.65 when set to off, value is not realistic anyway value = value < -250 ? 0 : value; result[postfixWithEndpointName('occupied_heating_setpoint', msg, model)] = value; } if (msg.data.hasOwnProperty('unoccupiedHeatingSetpoint')) { result[postfixWithEndpointName('unoccupied_heating_setpoint', msg, model)] = precisionRound(msg.data['unoccupiedHeatingSetpoint'], 2) / 100; } if (msg.data.hasOwnProperty('occupiedCoolingSetpoint')) { result[postfixWithEndpointName('occupied_cooling_setpoint', msg, model)] = precisionRound(msg.data['occupiedCoolingSetpoint'], 2) / 100; } if (msg.data.hasOwnProperty('unoccupiedCoolingSetpoint')) { result[postfixWithEndpointName('unoccupied_cooling_setpoint', msg, model)] = precisionRound(msg.data['unoccupiedCoolingSetpoint'], 2) / 100; } if (msg.data.hasOwnProperty('setpointChangeAmount')) { result[postfixWithEndpointName('setpoint_change_amount', msg, model)] = msg.data['setpointChangeAmount'] / 100; } if (msg.data.hasOwnProperty('setpointChangeSource')) { const lookup = {0: 'manual', 1: 'schedule', 2: 'externally'}; result[postfixWithEndpointName('setpoint_change_source', msg, model)] = lookup[msg.data['setpointChangeSource']]; } if (msg.data.hasOwnProperty('setpointChangeSourceTimeStamp')) { const date = new Date(2000, 0, 1); date.setSeconds(msg.data['setpointChangeSourceTimeStamp']); const value = toLocalISOString(date); result[postfixWithEndpointName('setpoint_change_source_timestamp', msg, model)] = value; } if (msg.data.hasOwnProperty('remoteSensing')) { const value = msg.data['remoteSensing']; result[postfixWithEndpointName('remote_sensing', msg, model)] = { local_temperature: ((value & 1) > 0) ? 'remotely' : 'internally', outdoor_temperature: ((value & 1<<1) > 0) ? 'remotely' : 'internally', occupancy: ((value & 1<<2) > 0) ? 'remotely' : 'internally', }; } if (msg.data.hasOwnProperty('ctrlSeqeOfOper')) { result[postfixWithEndpointName('control_sequence_of_operation', msg, model)] = constants.thermostatControlSequenceOfOperations[msg.data['ctrlSeqeOfOper']]; } if (msg.data.hasOwnProperty('systemMode')) { result[postfixWithEndpointName('system_mode', msg, model)] = constants.thermostatSystemModes[msg.data['systemMode']]; } if (msg.data.hasOwnProperty('runningMode')) { result[postfixWithEndpointName('running_mode', msg, model)] = constants.thermostatRunningMode[msg.data['runningMode']]; } if (msg.data.hasOwnProperty('runningState')) { result[postfixWithEndpointName('running_state', msg, model)] = constants.thermostatRunningStates[msg.data['runningState']]; } if (msg.data.hasOwnProperty('pIHeatingDemand')) { result[postfixWithEndpointName('pi_heating_demand', msg, model)] = mapNumberRange(msg.data['pIHeatingDemand'], 0, 255, 0, 100); } if (msg.data.hasOwnProperty('tempSetpointHold')) { result[postfixWithEndpointName('temperature_setpoint_hold', msg, model)] = msg.data['tempSetpointHold'] == 1; } if (msg.data.hasOwnProperty('tempSetpointHoldDuration')) { result[postfixWithEndpointName('temperature_setpoint_hold_duration', msg, model)] = msg.data['tempSetpointHoldDuration']; } return result; }, }, thermostat_weekly_schedule: { cluster: 'hvacThermostat', type: ['commandGetWeeklyScheduleRsp'], convert: (model, msg, publish, options, meta) => { const days = []; for (let i = 0; i < 8; i++) { if ((msg.data['dayofweek'] & 1<<i) > 0) { days.push(constants.dayOfWeek[i]); } } const transitions = []; for (const transition of msg.data.transitions) { const entry = {time: transition.transitionTime}; if (transition.hasOwnProperty('heatSetpoint')) { entry['heating_setpoint'] = transition['heatSetpoint'] / 100; } if (transition.hasOwnProperty('coolSetpoint')) { entry['cooling_setpoint'] = transition['coolSetpoint'] / 100; } transitions.push(entry); } return {[postfixWithEndpointName('weekly_schedule', msg, model)]: {days, transitions}}; }, }, hvac_user_interface: { cluster: 'hvacUserInterfaceCfg', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const result = {}; if (msg.data.hasOwnProperty('keypadLockout')) { result.keypad_lockout = constants.keypadLockoutMode.hasOwnProperty(msg.data['keypadLockout']) ? constants.keypadLockoutMode[msg.data['keypadLockout']] : msg.data['keypadLockout']; } return result; }, }, lock_operation_event: { cluster: 'closuresDoorLock', type: 'commandOperationEventNotification', convert: (model, msg, publish, options, meta) => { const lookup = { 0: 'unknown', 1: 'lock', 2: 'unlock', 3: 'lock_failure_invalid_pin_or_id', 4: 'lock_failure_invalid_schedule', 5: 'unlock_failure_invalid_pin_or_id', 6: 'unlock_failure_invalid_schedule', 7: 'one_touch_lock', 8: 'key_lock', 9: 'key_unlock', 10: 'auto_lock', 11: 'schedule_lock', 12: 'schedule_unlock', 13: 'manual_lock', 14: 'manual_unlock', 15: 'non_access_user_operational_event', }; return { action: lookup[msg.data['opereventcode']], action_user: msg.data['userid'], action_source: msg.data['opereventsrc'], action_source_name: constants.lockSourceName[msg.data['opereventsrc']], }; }, }, lock_programming_event: { cluster: 'closuresDoorLock', type: 'commandProgrammingEventNotification', convert: (model, msg, publish, options, meta) => { const lookup = { 0: 'unknown', 1: 'master_code_changed', 2: 'pin_code_added', 3: 'pin_code_deleted', 4: 'pin_code_changed', 5: 'rfid_code_added', 6: 'rfid_code_deleted', }; return { action: lookup[msg.data['programeventcode']], action_user: msg.data['userid'], action_source: msg.data['programeventsrc'], action_source_name: constants.lockSourceName[msg.data['programeventsrc']], }; }, }, lock: { cluster: 'closuresDoorLock', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const result = {}; if (msg.data.hasOwnProperty('lockState')) { result.state = msg.data.lockState == 1 ? 'LOCK' : 'UNLOCK'; const lookup = ['not_fully_locked', 'locked', 'unlocked']; result.lock_state = lookup[msg.data['lockState']]; } if (msg.data.hasOwnProperty('autoRelockTime')) { result.auto_relock_time = msg.data.autoRelockTime; } if (msg.data.hasOwnProperty('soundVolume')) { result.sound_volume = constants.lockSoundVolume[msg.data.soundVolume]; } if (msg.data.hasOwnProperty('doorState')) { const lookup = { 0: 'open', 1: 'closed', 2: 'error_jammed', 3: 'error_forced_open', 4: 'error_unspecified', 0xff: 'undefined'}; result.door_state = lookup[msg.data['doorState']]; } return result; }, }, lock_pin_code_response: { cluster: 'closuresDoorLock', type: ['commandGetPinCodeRsp'], convert: (model, msg, publish, options, meta) => { const {data} = msg; let status = constants.lockUserStatus[data.userstatus]; if (status === undefined) { status = `not_supported_${data.userstatus}`; } const userId = data.userid.toString(); const result = {users: {}}; result.users[userId] = {status: status}; if (options && options.expose_pin && data.pincodevalue) { result.users[userId].pin_code = data.pincodevalue; } return result; }, }, lock_user_status_response: { cluster: 'closuresDoorLock', type: ['commandGetUserStatusRsp'], convert: (model, msg, publish, options, meta) => { const {data} = msg; let status = constants.lockUserStatus[data.userstatus]; if (status === undefined) { status = `not_supported_${data.userstatus}`; } const userId = data.userid.toString(); const result = {users: {}}; result.users[userId] = {status: status}; if (options && options.expose_pin && data.pincodevalue) { result.users[userId].pin_code = data.pincodevalue; } return result; }, }, linkquality_from_basic: { cluster: 'genBasic', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { return {linkquality: msg.linkquality}; }, }, battery: { cluster: 'genPowerCfg', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const payload = {}; if (msg.data.hasOwnProperty('batteryPercentageRemaining')) { // Some devices do not comply to the ZCL and report a // batteryPercentageRemaining of 100 when the battery is full (should be 200). const dontDividePercentage = model.meta && model.meta.battery && model.meta.battery.dontDividePercentage; let percentage = msg.data['batteryPercentageRemaining']; percentage = dontDividePercentage ? percentage : percentage / 2; payload.battery = precisionRound(percentage, 2); } if (msg.data.hasOwnProperty('batteryVoltage')) { // Deprecated: voltage is = mV now but should be V payload.voltage = msg.data['batteryVoltage'] * 100; if (model.meta && model.meta.battery && model.meta.battery.voltageToPercentage) { payload.battery = batteryVoltageToPercentage(payload.voltage, model.meta.battery.voltageToPercentage); } } if (msg.data.hasOwnProperty('batteryAlarmState')) { const battery1Low = (msg.data.batteryAlarmState & 1<<0) > 0; const battery2Low = (msg.data.batteryAlarmState & 1<<9) > 0; const battery3Low = (msg.data.batteryAlarmState & 1<<19) > 0; payload.battery_low = battery1Low || battery2Low || battery3Low; } return payload; }, }, temperature: { cluster: 'msTemperatureMeasurement', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const temperature = parseFloat(msg.data['measuredValue']) / 100.0; const property = postfixWithEndpointName('temperature', msg, model); return {[property]: calibrateAndPrecisionRoundOptions(temperature, options, 'temperature')}; }, }, device_temperature: { cluster: 'genDeviceTempCfg', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { if (msg.data.hasOwnProperty('currentTemperature')) { return {device_temperature: parseInt(msg.data['currentTemperature'])}; } }, }, humidity: { cluster: 'msRelativeHumidity', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const humidity = parseFloat(msg.data['measuredValue']) / 100.0; // https://github.com/Koenkk/zigbee2mqtt/issues/798 // Sometimes the sensor publishes non-realistic vales, it should only publish message // in the 0 - 100 range, don't produce messages beyond these values. if (humidity >= 0 && humidity <= 100) { return {humidity: calibrateAndPrecisionRoundOptions(humidity, options, 'humidity')}; } }, }, soil_moisture: { cluster: 'msSoilMoisture', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const soilMoisture = parseFloat(msg.data['measuredValue']) / 100.0; return {soil_moisture: calibrateAndPrecisionRoundOptions(soilMoisture, options, 'soil_moisture')}; }, }, illuminance: { cluster: 'msIlluminanceMeasurement', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { // DEPRECATED: only return lux here (change illuminance_lux -> illuminance) const illuminance = msg.data['measuredValue']; const illuminanceLux = illuminance === 0 ? 0 : Math.pow(10, (illuminance - 1) / 10000); return { illuminance: calibrateAndPrecisionRoundOptions(illuminance, options, 'illuminance'), illuminance_lux: calibrateAndPrecisionRoundOptions(illuminanceLux, options, 'illuminance_lux'), }; }, }, pressure: { cluster: 'msPressureMeasurement', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { let pressure = 0; if (msg.data.hasOwnProperty('scaledValue')) { const scale = msg.endpoint.getClusterAttributeValue('msPressureMeasurement', 'scale'); pressure = msg.data['scaledValue'] / Math.pow(10, scale) / 100.0; // convert to hPa } else { pressure = parseFloat(msg.data['measuredValue']); } return {pressure: calibrateAndPrecisionRoundOptions(pressure, options, 'pressure')}; }, }, co2: { cluster: 'msCO2', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { return {co2: Math.floor(msg.data.measuredValue * 1000000)}; }, }, occupancy: { // This is for occupancy sensor that send motion start AND stop messages cluster: 'msOccupancySensing', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { if (msg.data.hasOwnProperty('occupancy')) { return {occupancy: (msg.data.occupancy % 2) > 0}; } }, }, occupancy_with_timeout: { // This is for occupancy sensor that only send a message when motion detected, // but do not send a motion stop. // Therefore we need to publish the no_motion detected by ourselves. cluster: 'msOccupancySensing', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { if (msg.data.occupancy !== 1) { // In case of 0 no occupancy is reported. // https://github.com/Koenkk/zigbee2mqtt/issues/467 return; } // The occupancy sensor only sends a message when motion detected. // Therefore we need to publish the no_motion detected by ourselves. const timeout = options && options.hasOwnProperty('occupancy_timeout') ? options.occupancy_timeout : 90; // Stop existing timers because motion is detected and set a new one. globalStore.getValue(msg.endpoint, 'timers', []).forEach((t) => clearTimeout(t)); globalStore.putValue(msg.endpoint, 'timers', []); if (timeout !== 0) { const timer = setTimeout(() => { publish({occupancy: false}); }, timeout * 1000); globalStore.getValue(msg.endpoint, 'timers').push(timer); } // No occupancy since if (options && options.no_occupancy_since) { options.no_occupancy_since.forEach((since) => { const timer = setTimeout(() => { publish({no_occupancy_since: since}); }, since * 1000); globalStore.getValue(msg.endpoint, 'timers').push(timer); }); } if (options && options.no_occupancy_since) { return {occupancy: true, no_occupancy_since: 0}; } else { return {occupancy: true}; } }, }, occupancy_timeout: { cluster: 'msOccupancySensing', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { if (msg.data.hasOwnProperty('pirOToUDelay')) { return {occupancy_timeout: msg.data.pirOToUDelay}; } }, }, brightness: { cluster: 'genLevelCtrl', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { if (msg.data.hasOwnProperty('currentLevel')) { const property = postfixWithEndpointName('brightness', msg, model); return {[property]: msg.data['currentLevel']}; } }, }, level_config: { cluster: 'genLevelCtrl', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const result = {'level_config': {}}; // onOffTransitionTime - range 0x0000 to 0xffff - optional if (msg.data.hasOwnProperty('onOffTransitionTime') && (msg.data['onOffTransitionTime'] !== undefined)) { result.level_config.on_off_transition_time = Number(msg.data['onOffTransitionTime']); } // onTransitionTime - range 0x0000 to 0xffff - optional // 0xffff = use onOffTransitionTime if (msg.data.hasOwnProperty('onTransitionTime') && (msg.data['onTransitionTime'] !== undefined)) { result.level_config.on_transition_time = Number(msg.data['onTransitionTime']); if (result.level_config.on_transition_time == 65535) { result.level_config.on_transition_time = 'disabled'; } } // offTransitionTime - range 0x0000 to 0xffff - optional // 0xffff = use onOffTransitionTime if (msg.data.hasOwnProperty('offTransitionTime') && (msg.data['offTransitionTime'] !== undefined)) { result.level_config.off_transition_time = Number(msg.data['offTransitionTime']); if (result.level_config.off_transition_time == 65535) { result.level_config.off_transition_time = 'disabled'; } } // startUpCurrentLevel - range 0x00 to 0xff - optional // 0x00 = return to minimum supported level // 0xff - return to previous previous if (msg.data.hasOwnProperty('startUpCurrentLevel') && (msg.data['startUpCurrentLevel'] !== undefined)) { result.level_config.current_level_startup = Number(msg.data['startUpCurrentLevel']); if (result.level_config.current_level_startup == 255) { result.level_config.current_level_startup = 'previous'; } if (result.level_config.current_level_startup == 0) { result.level_config.current_level_startup = 'minimum'; } } if (Object.keys(result.level_config).length > 0) { return result; } }, }, color_colortemp: { cluster: 'lightingColorCtrl', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const result = {}; if (msg.data.hasOwnProperty('colorTemperature')) { result.color_temp = msg.data['colorTemperature']; } if (msg.data.hasOwnProperty('startUpColorTemperature')) { result.color_temp_startup = msg.data['startUpColorTemperature']; } if (msg.data.hasOwnProperty('colorMode')) { result.color_mode = constants.colorMode.hasOwnProperty(msg.data['colorMode']) ? constants.colorMode[msg.data['colorMode']] : msg.data['colorMode']; } if ( msg.data.hasOwnProperty('currentX') || msg.data.hasOwnProperty('currentY') || msg.data.hasOwnProperty('currentSaturation') || msg.data.hasOwnProperty('currentHue') || msg.data.hasOwnProperty('enhancedCurrentHue') ) { result.color = {}; if (msg.data.hasOwnProperty('currentX')) { result.color.x = mapNumberRange(msg.data['currentX'], 0, 65535, 0, 1, 4); } if (msg.data.hasOwnProperty('currentY')) { result.color.y = mapNumberRange(msg.data['currentY'], 0, 65535, 0, 1, 4); } if (msg.data.hasOwnProperty('currentSaturation')) { result.color.saturation = mapNumberRange(msg.data['currentSaturation'], 0, 254, 0, 100); } if (msg.data.hasOwnProperty('currentHue')) { result.color.hue = mapNumberRange(msg.data['currentHue'], 0, 254, 0, 360, 0); } if (msg.data.hasOwnProperty('enhancedCurrentHue')) { result.color.hue = mapNumberRange(msg.data['enhancedCurrentHue'], 0, 65535, 0, 360, 1); } } if (msg.data.hasOwnProperty('options')) { /* * Bit | Value & Summary * -------------------------- * 0 | 0: Do not execute command if the On/Off cluster, OnOff attribute is 0x00 (FALSE) * | 1: Execute command if the On/Off cluster, OnOff attribute is 0x00 (FALSE) */ result.color_options = {execute_if_off: ((msg.data.options & 1<<0) > 0)}; } // handle color property sync // NOTE: this should the last thing we do, as we need to have processed all attributes, // we use assign here so we do not lose other attributes. return Object.assign(result, libColor.syncColorState(result, meta.state, options)); }, }, metering: { /** * When using this converter also add the following to the configure method of the device: * await readMeteringPowerConverterAttributes(endpoint); */ cluster: 'seMetering', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const payload = {}; const multiplier = msg.endpoint.getClusterAttributeValue('seMetering', 'multiplier'); const divisor = msg.endpoint.getClusterAttributeValue('seMetering', 'divisor'); const factor = multiplier && divisor ? multiplier / divisor : null; if (msg.data.hasOwnProperty('instantaneousDemand')) { let power = msg.data['instantaneousDemand']; if (factor != null) { power = (power * factor) * 1000; // kWh to Watt } payload.power = precisionRound(power, 2); } if (factor != null && (msg.data.hasOwnProperty('currentSummDelivered') || msg.data.hasOwnProperty('currentSummReceived'))) { let energy = 0; if (msg.data.hasOwnProperty('currentSummDelivered')) { const data = msg.data['currentSummDelivered']; const value = (parseInt(data[0]) << 32) + parseInt(data[1]); energy += value * factor; } if (msg.data.hasOwnProperty('currentSummReceived')) { const data = msg.data['currentSummReceived']; const value = (parseInt(data[0]) << 32) + parseInt(data[1]); energy -= value * factor; } payload.energy = precisionRound(energy, 2); } return payload; }, }, electrical_measurement: { /** * When using this converter also add the following to the configure method of the device: * await readEletricalMeasurementConverterAttributes(endpoint); */ cluster: 'haElectricalMeasurement', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const getFactor = (key) => { const multiplier = msg.endpoint.getClusterAttributeValue('haElectricalMeasurement', `${key}Multiplier`); const divisor = msg.endpoint.getClusterAttributeValue('haElectricalMeasurement', `${key}Divisor`); const factor = multiplier && divisor ? multiplier / divisor : 1; return factor; }; const lookup = [ {key: 'activePower', name: 'power', factor: 'acPower'}, {key: 'activePowerPhB', name: 'power_phase_b', factor: 'acPower'}, {key: 'activePowerPhC', name: 'power_phase_c', factor: 'acPower'}, {key: 'rmsCurrent', name: 'current', factor: 'acCurrent'}, {key: 'rmsCurrentPhB', name: 'current_phase_b', factor: 'acCurrent'}, {key: 'rmsCurrentPhC', name: 'current_phase_c', factor: 'acCurrent'}, {key: 'rmsVoltage', name: 'voltage', factor: 'acVoltage'}, {key: 'rmsVoltagePhB', name: 'voltage_phase_b', factor: 'acVoltage'}, {key: 'rmsVoltagePhC', name: 'voltage_phase_c', factor: 'acVoltage'}, ]; const payload = {}; for (const entry of lookup) { if (msg.data.hasOwnProperty(entry.key)) { const factor = getFactor(entry.factor); const property = postfixWithEndpointName(entry.name, msg, model); payload[property] = precisionRound(msg.data[entry.key] * factor, 2); } } return payload; }, }, on_off: { cluster: 'genOnOff', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { if (msg.data.hasOwnProperty('onOff')) { const property = postfixWithEndpointName('state', msg, model); return {[property]: msg.data['onOff'] === 1 ? 'ON' : 'OFF'}; } }, }, on_off_skip_duplicate_transaction: { cluster: 'genOnOff', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { // Device sends multiple messages with the same transactionSequenceNumber, // prevent that multiple messages get send. // https://github.com/Koenkk/zigbee2mqtt/issues/3687 if (msg.data.hasOwnProperty('onOff') && !hasAlreadyProcessedMessage(msg)) { const property = postfixWithEndpointName('state', msg, model); return {[property]: msg.data['onOff'] === 1 ? 'ON' : 'OFF'}; } }, }, power_on_behavior: { cluster: 'genOnOff', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { const lookup = {0: 'off', 1: 'on', 2: 'toggle', 255: 'previous'}; if (msg.data.hasOwnProperty('startUpOnOff')) { const property = postfixWithEndpointName('power_on_behavior', msg, model); return {[property]: lookup[msg.data['startUpOnOff']]}; } }, }, ias_no_alarm: { cluster: 'ssIasZone', type: 'attributeReport', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zoneStatus; return { tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_water_leak_alarm_1: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; return { water_leak: (zoneStatus & 1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_water_leak_alarm_1_report: { cluster: 'ssIasZone', type: 'attributeReport', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zoneStatus; return { water_leak: (zoneStatus & 1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_vibration_alarm_1: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; return { vibration: (zoneStatus & 1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_vibration_alarm_1_with_timeout: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; const timeout = options && options.hasOwnProperty('vibration_timeout') ? options.vibration_timeout : 90; // Stop existing timers because vibration is detected and set a new one. globalStore.getValue(msg.endpoint, 'timers', []).forEach((t) => clearTimeout(t)); globalStore.putValue(msg.endpoint, 'timers', []); if (timeout !== 0) { const timer = setTimeout(() => { publish({vibration: false}); }, timeout * 1000); globalStore.getValue(msg.endpoint, 'timers').push(timer); } return { vibration: (zoneStatus & 1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_gas_alarm_1: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; return { gas: (zoneStatus & 1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_gas_alarm_2: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; return { gas: (zoneStatus & 1<<1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_smoke_alarm_1: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; return { smoke: (zoneStatus & 1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, supervision_reports: (zoneStatus & 1<<4) > 0, restore_reports: (zoneStatus & 1<<5) > 0, trouble: (zoneStatus & 1<<6) > 0, ac_status: (zoneStatus & 1<<7) > 0, test: (zoneStatus & 1<<8) > 0, }; }, }, ias_contact_alarm_1: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; const contactProperty = postfixWithEndpointName('contact', msg, model); const tamperProperty = postfixWithEndpointName('tamper', msg, model); const batteryLowProperty = postfixWithEndpointName('battery_low', msg, model); return { [contactProperty]: !((zoneStatus & 1) > 0), [tamperProperty]: (zoneStatus & 1<<2) > 0, [batteryLowProperty]: (zoneStatus & 1<<3) > 0, }; }, }, ias_contact_alarm_1_report: { cluster: 'ssIasZone', type: 'attributeReport', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zoneStatus; return { contact: !((zoneStatus & 1) > 0), tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_carbon_monoxide_alarm_1: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; return { carbon_monoxide: (zoneStatus & 1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_carbon_monoxide_alarm_1_gas_alarm_2: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const {zoneStatus} = msg.data; return { carbon_monoxide: (zoneStatus & 1) > 0, gas: (zoneStatus & 1 << 1) > 0, tamper: (zoneStatus & 1 << 2) > 0, battery_low: (zoneStatus & 1 << 3) > 0, trouble: (zoneStatus & 1 << 6) > 0, ac_connected: !((zoneStatus & 1 << 7) > 0), test: (zoneStatus & 1 << 8) > 0, battery_defect: (zoneStatus & 1 << 9) > 0, }; }, }, ias_sos_alarm_2: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; return { sos: (zoneStatus & 1<<1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_occupancy_alarm_1: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; return { occupancy: (zoneStatus & 1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_occupancy_alarm_2: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; return { occupancy: (zoneStatus & 1<<1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, ias_occupancy_alarm_1_with_timeout: { cluster: 'ssIasZone', type: 'commandStatusChangeNotification', convert: (model, msg, publish, options, meta) => { const zoneStatus = msg.data.zonestatus; const timeout = options && options.hasOwnProperty('occupancy_timeout') ? options.occupancy_timeout : 90; clearTimeout(globalStore.getValue(msg.endpoint, 'timer')); if (timeout !== 0) { const timer = setTimeout(() => publish({occupancy: false}), timeout * 1000); globalStore.putValue(msg.endpoint, 'timer', timer); } return { occupancy: (zoneStatus & 1) > 0, tamper: (zoneStatus & 1<<2) > 0, battery_low: (zoneStatus & 1<<3) > 0, }; }, }, command_recall: { cluster: 'genScenes', type: 'commandRecall', convert: (model, msg, publish, options, meta) => { const payload = {action: postfixWithEndpointName(`recall_${msg.data.sceneid}`, msg, model)}; addActionGroup(payload, msg, model); return payload; }, }, command_panic: { cluster: 'ssIasAce', type: 'commandPanic', convert: (model, msg, publish, options, meta) => { const payload = {action: postfixWithEndpointName(`panic`, msg, model)}; addActionGroup(payload, msg, model); return payload; }, }, command_arm: { cluster: 'ssIasAce', type: 'commandArm', convert: (model, msg, publish, options, meta) => { if (hasAlreadyProcessedMessage(msg)) return; const payload = { action: postfixWithEndpointName(constants.armMode[msg.data['armmode']], msg, model), action_code: msg.data.code, action_zone: msg.data.zoneid, }; if (msg.groupID) payload.action_group = msg.groupID; return payload; }, }, command_arm_with_transaction: { cluster: 'ssIasAce', type: 'commandArm', convert: (model, msg, publish, options, meta) => { const payload = converters.command_arm.convert(model, msg, publish, options, meta); if (!payload) return; payload.action_transaction = msg.meta.zclTransactionSequenceNumber; return payload; }, }, command_cover_stop: { cluster: 'closuresWindowCovering', type: 'commandStop', convert: (model, msg, publish, options, meta) => { const payload = {action: postfixWithEndpointName('stop', msg, model)}; addActionGroup(payload, msg, model); return payload; }, }, command_cover_open: { cluster: 'closuresWindowCovering', type: 'commandUpOpen', convert: (model, msg, publish, options, meta) => { const payload = {action: postfixWithEndpointName('open', msg, model)}; addActionGroup(payload, msg, model); return payload; }, }, command_cover_close: { cluster: 'closuresWindowCovering', type: 'commandDownClose', convert: (model, msg, publish, options, meta) => { const payload = {action: postfixWithEndpointName('close', msg, model)}; addActionGroup(payload, msg, model); return payload; }, }, command_on: { cluster: 'genOnOff', type: 'commandOn', convert: (model, msg, publish, options, meta) => { const payload = {action: postfixWithEndpointName('on', msg, model)}; addActionGroup(payload, msg, model); return payload; }, }, command_off: { cluster: 'genOnOff', type: 'commandOff', convert: (model, msg, publish, options, meta) => { const payload = {action: postfixWithEndpointName('off', msg, model)}; addActionGroup(payload, msg, model); return payload; }, }, command_off_with_effect: { cluster: 'genOnOff', type: 'commandOffWithEffect', convert: (model, msg, publish, options, meta) => { const payload = {action: postfixWithEndpointName(`off`, msg, model)}; addActionGroup(payload, msg, model); return payload; }, }, command_toggle: { cluster: 'genOnOff', type: 'commandToggle', convert: (model, msg, publish, options, meta) => { const payload = {action: postfixWithEndpointName('toggle', msg, model)}; addActionGroup(payload, msg, model); return payload; }, }, command_move_to_level: { cluster: 'genLevelCtrl', type: ['commandMoveToLevel', 'commandMoveToLevelWithOnOff'], convert: (model, msg, publish, options, meta) => { const payload = { action: postfixWithEndpointName(`brightness_move_to_level`, msg, model), action_level: msg.data.level, action_transition_time: msg.data.transtime / 100, }; addActionGroup(payload, msg, model); if (options.simulated_brightness) { globalStore.putValue(msg.endpoint, 'simulated_brightness_brightness', msg.data.level); payload.brightness = msg.data.level; } return payload; }, }, command_move: { cluster: 'genLevelCtrl', type: ['commandMove', 'commandMoveWithOnOff'], convert: (model, msg, publish, options, meta) => { const direction = msg.data.movemode === 1 ? 'down' : 'up'; const action = postfixWithEndpointName(`brightness_move_${direction}`, msg, model); const payload = {action, action_rate: msg.data.rate}; addActionGroup(payload, msg, model); if (options.simulated_brightness) { const opts = options.simulated_brightness; const deltaOpts = typeof opts === 'object' && opts.hasOwnProperty('delta') ? opts.delta : 20; const intervalOpts = typeof opts === 'object' && opts.hasOwnProperty('interval') ? opts.interval : 200; globalStore.putValue(msg.endpoint, 'simulated_brightness_direction', direction); if (globalStore.getValue(msg.endpoint, 'simulated_brightness_timer') === undefined) { const timer = setInterval(() => { let brightness = globalStore.getValue(msg.endpoint, 'simulated_brightness_brightness', 255); const delta = globalStore.getValue(msg.endpoint, 'simulated_brightness_direction') === 'up' ? deltaOpts : -1 * deltaOpts; brightness += delta; brightness = numberWithinRange(brightness, 0, 255); globalStore.putValue(msg.endpoint, 'simulated_brightness_brightness', brightness); publish({brightness}); }, intervalOpts); globalStore.putValue(msg.endpoint, 'simulated_brightness_timer', timer); } } return payload; }, }, command_step: { cluster: 'genLevelCtrl', type: ['commandStep', 'commandStepWithOnOff'], convert: (model, msg, publish, options, meta) => { const direction = msg.data.stepmode === 1 ? 'down' : 'up'; const payload = { action: postfixWithEndpointName(`brightness_step_${direction}`, msg, model), action_step_size: msg.data.stepsize, action_transition_time: msg.data.transtime / 100, }; addActionGroup(payload, msg, model); if (options.simulated_brightness) { let brightness = globalStore.getValue(msg.endpoint, 'simulated_brightness_brightness', 255); const delta = direction === 'up' ? msg.data.stepsize : -1 * msg.data.stepsize; brightness += delta; brightness = numberWithinRange(brightness, 0, 255); globalStore.putValue(msg.endpoint, 'simulated_brightness_brightness', brightness); payload.brightness = brightness; } return payload; }, }, command_stop: { cluster: 'genLevelCtrl', type: ['commandStop', 'commandStopWithOnOff'], convert: (model, msg, publish, options, meta) => { if (options.simulated_brightness) { clearInterval(globalStore.getValue(msg.endpoint, 'simulated_brightness_timer')); globalStore.putValue(msg.endpoint, 'simulated_brightness_timer', undefined); } const payload = {action: postfixWithEndpointName(`brightness_stop`, msg, model)}; addActionGroup(payload, msg, model); return payload; }, }, command_move_color_temperature: { cluster: 'lightingColorCtrl', type: ['commandMoveColorTemp'], convert: (model, msg, publish, options, meta) => { const direction = msg.data.movemode === 1 ? 'down' : 'up'; const action = postfixWithEndpointName(`color_temperature_move_${direction}`, msg, model); const payload = {action, action_rate: msg.data.rate, action_minimum: msg.data.minimum, action_maximum: msg.data.maximum}; addActionGroup(payload, msg, model); return payload; }, }, command_step_color_temperature: { cluster: 'lightingColorCtrl', type: 'commandStepColorTemp', convert: (model, msg, publish, options, meta) => { const direction = msg.data.stepmode === 1 ? 'up' : 'down'; const payload = { action: postfixWithEndpointName(`color_temperature_step_${direction}`, msg, model), action_step_size: msg.data.stepsize, }; if (msg.data.hasOwnProperty('transtime')) { payload.action_transition_time = msg.data.transtime / 100; } addActionGroup(payload, msg, model); return payload; }, }, command_ehanced_move_to_hue_and_saturation: { cluster: 'lightingColorCtrl', type: 'commandEnhancedMoveToHueAndSaturation', convert: (model, msg, publish, options, meta) => { const payload = { action: postfixWithEndpointName(`enhanced_move_to_hue_and_saturation`, msg, model),