zigbee-herdsman-converters
Version:
Collection of device converters to be used with zigbee-herdsman
1,140 lines (1,077 loc) • 264 kB
JavaScript
'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),