UNPKG

zigbee-shepherd-converters

Version:

Collection of device converters to be used with zigbee-shepherd

1,380 lines (1,295 loc) 128 kB
'use strict'; const common = require('./common'); const clickLookup = { 1: 'single', 2: 'double', 3: 'triple', 4: 'quadruple', }; const occupancyTimeout = 90; // In seconds const defaultPrecision = { temperature: 2, humidity: 2, pressure: 1, }; const precisionRoundOptions = (number, options, type) => { const key = `${type}_precision`; const defaultValue = defaultPrecision[type]; const precision = options && options.hasOwnProperty(key) ? options[key] : defaultValue; return precisionRound(number, precision); }; const precisionRound = (number, precision) => { const factor = Math.pow(10, precision); return Math.round(number * factor) / factor; }; const calibrateOptions = (number, options, type) => { const key = `${type}_calibration`; let calibrationOffset = options && options.hasOwnProperty(key) ? options[key] : 0; if (type == 'illuminance') { // linear calibration because measured value is zero based // +/- percent calibrationOffset = Math.round(number * calibrationOffset / 100); } return number + calibrationOffset; }; const toPercentage = (value, min, max) => { if (value > max) { value = max; } else if (value < min) { value = min; } const normalised = (value - min) / (max - min); return (normalised * 100).toFixed(2); }; const toPercentageCR2032 = (voltage) => { let percentage = null; if (voltage < 2100) { percentage = 0; } else if (voltage < 2440) { percentage = 6 - ((2440 - voltage) * 6) / 340; } else if (voltage < 2740) { percentage = 18 - ((2740 - voltage) * 12) / 300; } else if (voltage < 2900) { percentage = 42 - ((2900 - voltage) * 24) / 160; } else if (voltage < 3000) { percentage = 100 - ((3000 - voltage) * 58) / 100; } else if (voltage >= 3000) { percentage = 100; } return Math.round(percentage); }; const numberWithinRange = (number, min, max) => { if (number > max) { return max; } else if (number < min) { return min; } else { return number; } }; // get object property name (key) by it's value const getKey = (object, value) => { for (const key in object) { if (object[key]==value) return key; } }; // Global variable store that can be used by devices. const store = {}; const ictcg1 = (model, msg, publish, options, action) => { const deviceID = msg.endpoints[0].device.ieeeAddr; const payload = {}; if (!store[deviceID]) { store[deviceID] = {since: false, direction: false, value: 255, publish: publish}; } const s = store[deviceID]; if (action === 'move') { s.since = Date.now(); const direction = msg.data.data.movemode === 1 ? 'left' : 'right'; s.direction = direction; payload.action = `rotate_${direction}`; } else if (action === 'stop' || action === 'level') { if (action === 'level') { s.value = msg.data.data.level; const direction = s.value === 0 ? 'left' : 'right'; payload.action = `rotate_${direction}_quick`; payload.brightness = s.value; } else { const duration = Date.now() - s.since; const delta = Math.round((duration / 10) * (s.direction === 'left' ? -1 : 1)); const newValue = s.value + delta; if (newValue >= 0 && newValue <= 255) { s.value = newValue; } payload.action = 'rotate_stop'; payload.brightness = s.value; } } if (s.timerId) { clearInterval(s.timerId); s.timerId = false; } if (action === 'move') { s.timerId = setInterval(() => { const duration = Date.now() - s.since; const delta = Math.round((duration / 10) * (s.direction === 'left' ? -1 : 1)); const newValue = s.value + delta; if (newValue >= 0 && newValue <= 255) { s.value = newValue; } payload.brightness = s.value; s.since = Date.now(); s.publish(payload); }, 10); } s.publish(payload); }; const ratelimitedDimmer = (model, msg, publish, options) => { const deviceID = msg.endpoints[0].device.ieeeAddr; const payload = {}; let duration = 0; if (!store[deviceID]) { store[deviceID] = {lastmsg: false}; } const s = store[deviceID]; if (s.lastmsg) { duration = Date.now() - s.lastmsg; } else { s.lastmsg = Date.now(); } if (duration > 500) { s.lastmsg = Date.now(); payload.action = 'brightness'; payload.brightness = msg.data.data.level; publish(payload); } }; const holdUpdateBrightness324131092621 = (deviceID) => { if (store[deviceID] && store[deviceID].brightnessSince && store[deviceID].brightnessDirection) { const duration = Date.now() - store[deviceID].brightnessSince; const delta = (duration / 10) * (store[deviceID].brightnessDirection === 'up' ? 1 : -1); const newValue = store[deviceID].brightnessValue + delta; store[deviceID].brightnessValue = numberWithinRange(newValue, 1, 255); } }; const converters = { HS2SK_power: { cid: 'haElectricalMeasurement', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const result = {}; if (msg.data.data.hasOwnProperty('activePower')) { result.power = msg.data.data['activePower'] / 10; } if (msg.data.data.hasOwnProperty('rmsCurrent')) { result.current = msg.data.data['rmsCurrent'] / 100; } if (msg.data.data.hasOwnProperty('rmsVoltage')) { result.voltage = msg.data.data['rmsVoltage'] / 100; } return result; }, }, generic_lock: { cid: 'closuresDoorLock', type: ['attReport', 'readRsp', 'devChange'], convert: (model, msg, publish, options) => { if (msg.data.data.hasOwnProperty('lockState')) { return {state: msg.data.data.lockState == 2 ? 'UNLOCK' : 'LOCK'}; } }, }, generic_lock_operation_event: { cid: 'closuresDoorLock', type: 'cmdOperationEventNotification', convert: (model, msg, publish, options) => { return { state: msg.data.data['opereventcode'] == 2 ? 'UNLOCK' : 'LOCK', user: msg.data.data['userid'], source: msg.data.data['opereventsrc'], }; }, }, genOnOff_cmdOn: { cid: 'genOnOff', type: 'cmdOn', convert: (model, msg, publish, options) => { return {click: 'on'}; }, }, genOnOff_cmdOff: { cid: 'genOnOff', type: 'cmdOff', convert: (model, msg, publish, options) => { return {click: 'off'}; }, }, E1743_brightness_up: { cid: 'genLevelCtrl', type: 'cmdMove', convert: (model, msg, publish, options) => { return {click: 'brightness_down'}; }, }, E1743_brightness_down: { cid: 'genLevelCtrl', type: 'cmdMoveWithOnOff', convert: (model, msg, publish, options) => { return {click: 'brightness_up'}; }, }, E1743_brightness_stop: { cid: 'genLevelCtrl', type: 'cmdStopWithOnOff', convert: (model, msg, publish, options) => { return {click: 'brightness_stop'}; }, }, AC0251100NJ_long_middle: { cid: 'lightingColorCtrl', type: 'cmdMoveHue', convert: (model, msg, publish, options) => { return {click: 'long_middle'}; }, }, AV2010_34_click: { cid: 'genScenes', type: 'cmdRecall', convert: (model, msg, publish, options) => { return {click: msg.data.data.groupid}; }, }, bitron_power: { cid: 'seMetering', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { return {power: parseFloat(msg.data.data['instantaneousDemand']) / 10.0}; }, }, bitron_occupancy: { cid: 'ssIasZone', type: 'statusChange', convert: (model, msg, publish, options) => { // The occupancy sensor only sends a message when motion detected. // Therefore we need to publish the no_motion detected by ourselves. const useOptionsTimeout = options && options.hasOwnProperty('occupancy_timeout'); const timeout = useOptionsTimeout ? options.occupancy_timeout : occupancyTimeout; const deviceID = msg.endpoints[0].device.ieeeAddr; // Stop existing timer because motion is detected and set a new one. if (store[deviceID]) { clearTimeout(store[deviceID]); store[deviceID] = null; } if (timeout !== 0) { store[deviceID] = setTimeout(() => { publish({occupancy: false}); store[deviceID] = null; }, timeout * 1000); } return {occupancy: true}; }, }, bitron_battery_att_report: { cid: 'genPowerCfg', type: 'attReport', convert: (model, msg, publish, options) => { const result = {}; if (typeof msg.data.data['batteryVoltage'] == 'number') { const battery = {max: 3200, min: 2500}; const voltage = msg.data.data['batteryVoltage'] * 100; result.battery = toPercentage(voltage, battery.min, battery.max); result.voltage = voltage; } if (typeof msg.data.data['batteryAlarmState'] == 'number') { result.battery_alarm_state = msg.data.data['batteryAlarmState']; } return result; }, }, bitron_battery_dev_change: { cid: 'genPowerCfg', type: 'devChange', convert: (model, msg, publish, options) => { const result = {}; if (typeof msg.data.data['batteryVoltage'] == 'number') { const battery = {max: 3200, min: 2500}; const voltage = msg.data.data['batteryVoltage'] * 100; result.battery = toPercentage(voltage, battery.min, battery.max); result.voltage = voltage; } if (typeof msg.data.data['batteryAlarmState'] == 'number') { result.battery_alarm_state = msg.data.data['batteryAlarmState']; } return result; }, }, bitron_thermostat_att_report: { cid: 'hvacThermostat', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const result = {}; if (typeof msg.data.data['localTemp'] == 'number') { result.local_temperature = precisionRound(msg.data.data['localTemp'], 2) / 100; } if (typeof msg.data.data['localTemperatureCalibration'] == 'number') { result.local_temperature_calibration = precisionRound(msg.data.data['localTemperatureCalibration'], 2) / 10; } if (typeof msg.data.data['occupiedHeatingSetpoint'] == 'number') { result.occupied_heating_setpoint = precisionRound(msg.data.data['occupiedHeatingSetpoint'], 2) / 100; } if (typeof msg.data.data['runningState'] == 'number') { result.running_state = msg.data.data['runningState']; } if (typeof msg.data.data['batteryAlarmState'] == 'number') { result.battery_alarm_state = msg.data.data['batteryAlarmState']; } return result; }, }, bitron_thermostat_dev_change: { cid: 'hvacThermostat', type: 'devChange', convert: (model, msg, publish, options) => { const result = {}; if (typeof msg.data.data['localTemp'] == 'number') { result.local_temperature = precisionRound(msg.data.data['localTemp'], 2) / 100; } if (typeof msg.data.data['localTemperatureCalibration'] == 'number') { result.local_temperature_calibration = precisionRound(msg.data.data['localTemperatureCalibration'], 2) / 10; } if (typeof msg.data.data['occupiedHeatingSetpoint'] == 'number') { result.occupied_heating_setpoint = precisionRound(msg.data.data['occupiedHeatingSetpoint'], 2) / 100; } if (typeof msg.data.data['runningState'] == 'number') { result.running_state = msg.data.data['runningState']; } if (typeof msg.data.data['batteryAlarmState'] == 'number') { result.battery_alarm_state = msg.data.data['batteryAlarmState']; } return result; }, }, nue_click: { cid: 'genScenes', type: 'cmdRecall', convert: (model, msg, publish, options) => { return {click: msg.data.data.sceneid}; }, }, smartthings_contact: { cid: 'ssIasZone', type: 'statusChange', convert: (model, msg, publish, options) => { return {contact: msg.data.zoneStatus === 48}; }, }, xiaomi_battery_3v: { cid: 'genBasic', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { let voltage = null; if (msg.data.data['65281']) { voltage = msg.data.data['65281']['1']; } else if (msg.data.data['65282']) { voltage = msg.data.data['65282']['1'].elmVal; } if (voltage) { return { battery: parseFloat(toPercentageCR2032(voltage)), voltage: voltage, }; } }, }, RTCGQ11LM_interval: { cid: 'genBasic', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data['65281']) { return {illuminance: msg.data.data['65281']['11']}; } }, }, WSDCGQ01LM_WSDCGQ11LM_interval: { cid: 'genBasic', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data['65281']) { const temperature = parseFloat(msg.data.data['65281']['100']) / 100.0; const humidity = parseFloat(msg.data.data['65281']['101']) / 100.0; const result = {}; // https://github.com/Koenkk/zigbee2mqtt/issues/798 // Sometimes the sensor publishes non-realistic vales, as the sensor only works from // -20 till +60, don't produce messages beyond these values. if (temperature > -25 && temperature < 65) { result.temperature = precisionRoundOptions(temperature, options, 'temperature'); } // in the 0 - 100 range, don't produce messages beyond these values. if (humidity >= 0 && humidity <= 100) { result.humidity = precisionRoundOptions(humidity, options, 'humidity'); } // Check if contains pressure (WSDCGQ11LM only) if (msg.data.data['65281'].hasOwnProperty('102')) { const pressure = parseFloat(msg.data.data['65281']['102']) / 100.0; result.pressure = precisionRoundOptions(pressure, options, 'pressure'); } return result; } }, }, WXKG01LM_click: { cid: 'genOnOff', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const deviceID = msg.endpoints[0].device.ieeeAddr; const state = msg.data.data['onOff']; if (!store[deviceID]) { store[deviceID] = {}; } // 0 = click down, 1 = click up, else = multiple clicks if (state === 0) { store[deviceID].timer = setTimeout(() => { publish({click: 'long'}); store[deviceID].timer = null; store[deviceID].long = Date.now(); }, options.long_timeout || 1000); // After 1000 milliseconds of not releasing we assume long click. } else if (state === 1) { if (store[deviceID].long) { const duration = Date.now() - store[deviceID].long; publish({click: 'long_release', duration: duration}); store[deviceID].long = false; } if (store[deviceID].timer) { clearTimeout(store[deviceID].timer); store[deviceID].timer = null; publish({click: 'single'}); } } else { const clicks = msg.data.data['32768']; const payload = clickLookup[clicks] ? clickLookup[clicks] : 'many'; publish({click: payload}); } }, }, generic_temperature: { cid: 'msTemperatureMeasurement', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const temperature = parseFloat(msg.data.data['measuredValue']) / 100.0; const calTemperature = calibrateOptions(temperature, options, 'temperature'); return {temperature: precisionRoundOptions(calTemperature, options, 'temperature')}; }, }, generic_temperature_change: { cid: 'msTemperatureMeasurement', type: 'devChange', convert: (model, msg, publish, options) => { const temperature = parseFloat(msg.data.data['measuredValue']) / 100.0; const calTemperature = calibrateOptions(temperature, options, 'temperature'); return {temperature: precisionRoundOptions(calTemperature, options, 'temperature')}; }, }, xiaomi_temperature: { cid: 'msTemperatureMeasurement', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const temperature = parseFloat(msg.data.data['measuredValue']) / 100.0; // https://github.com/Koenkk/zigbee2mqtt/issues/798 // Sometimes the sensor publishes non-realistic vales, as the sensor only works from // -20 till +60, don't produce messages beyond these values. if (temperature > -25 && temperature < 65) { return {temperature: precisionRoundOptions(temperature, options, 'temperature')}; } }, }, MFKZQ01LM_action_multistate: { cid: 'genMultistateInput', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { /* Source: https://github.com/kirovilya/ioBroker.zigbee +---+ | 2 | +---+---+---+ | 4 | 0 | 1 | +---+---+---+ |M5I| +---+ | 3 | +---+ Side 5 is with the MI logo, side 3 contains the battery door. presentValue = 0 = shake presentValue = 2 = wakeup presentValue = 3 = fly/fall presentValue = y + x * 8 + 64 = 90º Flip from side x on top to side y on top presentValue = x + 128 = 180º flip to side x on top presentValue = x + 256 = push/slide cube while side x is on top presentValue = x + 512 = double tap while side x is on top */ const value = msg.data.data['presentValue']; let action = null; if (value === 0) action = {'action': 'shake'}; else if (value === 2) action = {'action': 'wakeup'}; else if (value === 3) action = {'action': 'fall'}; else if (value >= 512) action = {'action': 'tap', 'side': value-512}; else if (value >= 256) action = {'action': 'slide', 'side': value-256}; else if (value >= 128) action = {'action': 'flip180', 'side': value-128}; else if (value >= 64) { action = {'action': 'flip90', 'from_side': Math.floor((value-64) / 8), 'to_side': value % 8}; } return action ? action : null; }, }, MFKZQ01LM_action_analog: { cid: 'genAnalogInput', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { /* Source: https://github.com/kirovilya/ioBroker.zigbee presentValue = rotation angle left < 0, right > 0 */ const value = msg.data.data['presentValue']; return { action: value < 0 ? 'rotate_left' : 'rotate_right', angle: Math.floor(value * 100) / 100, }; }, }, WXKG12LM_action_click_multistate: { cid: 'genMultistateInput', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const value = msg.data.data['presentValue']; const lookup = { 1: {click: 'single'}, // single click 2: {click: 'double'}, // double click 16: {action: 'hold'}, // hold for more than 400ms 17: {action: 'release'}, // release after hold for more than 400ms 18: {action: 'shake'}, // shake }; return lookup[value] ? lookup[value] : null; }, }, xiaomi_action_click_multistate: { cid: 'genMultistateInput', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const value = msg.data.data['presentValue']; const lookup = { 1: {click: 'single'}, // single click 2: {click: 'double'}, // double click 0: {action: 'hold'}, // hold for more than 400ms 255: {action: 'release'}, // release after hold for more than 400ms }; return lookup[value] ? lookup[value] : null; }, }, generic_humidity: { cid: 'msRelativeHumidity', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const humidity = parseFloat(msg.data.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: precisionRoundOptions(humidity, options, 'humidity')}; } }, }, generic_occupancy: { // This is for occupancy sensor that send motion start AND stop messages // Note: options.occupancy_timeout not available yet, to implement it will be // needed to update device report intervall as well, see devices.js cid: 'msOccupancySensing', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data.occupancy === 0) { return {occupancy: false}; } else if (msg.data.data.occupancy === 1) { return {occupancy: true}; } }, }, E1525_occupancy: { cid: 'genOnOff', type: 'cmdOnWithTimedOff', convert: (model, msg, publish, options) => { const timeout = msg.data.data.ontime / 10; const deviceID = msg.endpoints[0].device.ieeeAddr; // Stop existing timer because motion is detected and set a new one. if (store[deviceID]) { clearTimeout(store[deviceID]); store[deviceID] = null; } if (timeout !== 0) { store[deviceID] = setTimeout(() => { publish({occupancy: false}); store[deviceID] = null; }, timeout * 1000); } return {occupancy: true}; }, }, generic_occupancy_no_off_msg: { // 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. cid: 'msOccupancySensing', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.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 useOptionsTimeout = options && options.hasOwnProperty('occupancy_timeout'); const timeout = useOptionsTimeout ? options.occupancy_timeout : occupancyTimeout; const deviceID = msg.endpoints[0].device.ieeeAddr; // Stop existing timers because motion is detected and set a new one. if (store[deviceID]) { store[deviceID].forEach((t) => clearTimeout(t)); } store[deviceID] = []; if (timeout !== 0) { const timer = setTimeout(() => { publish({occupancy: false}); }, timeout * 1000); store[deviceID].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); store[deviceID].push(timer); }); } if (options && options.no_occupancy_since) { return {occupancy: true, no_occupancy_since: 0}; } else { return {occupancy: true}; } }, }, xiaomi_contact: { cid: 'genOnOff', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { return {contact: msg.data.data['onOff'] === 0}; }, }, xiaomi_contact_interval: { cid: 'genBasic', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data.hasOwnProperty('65281') && msg.data.data['65281'].hasOwnProperty('100')) { return {contact: msg.data.data['65281']['100'] === 0}; } }, }, brightness: { cid: 'genLevelCtrl', type: 'devChange', convert: (model, msg, publish, options) => { if (msg.data.data.hasOwnProperty('currentLevel')) { return {brightness: msg.data.data['currentLevel']}; } }, }, color_colortemp: { cid: 'lightingColorCtrl', type: 'devChange', convert: (model, msg, publish, options) => { const result = {}; if (msg.data.data['colorTemperature']) { result.color_temp = msg.data.data['colorTemperature']; } if (msg.data.data['colorMode']) { result.color_mode = msg.data.data['colorMode']; } if ( msg.data.data['currentX'] || msg.data.data['currentY'] || msg.data.data['currentSaturation'] || msg.data.data['enhancedCurrentHue'] ) { result.color = {}; if (msg.data.data['currentX']) { result.color.x = precisionRound(msg.data.data['currentX'] / 65535, 4); } if (msg.data.data['currentY']) { result.color.y = precisionRound(msg.data.data['currentY'] / 65535, 4); } if (msg.data.data['currentSaturation']) { result.color.saturation = precisionRound(msg.data.data['currentSaturation'] / 2.54, 1); } if (msg.data.data['enhancedCurrentHue']) { result.color.hue = precisionRound(msg.data.data['enhancedCurrentHue'] / (65535 / 360), 1); } } return result; }, }, color_colortemp_report: { cid: 'lightingColorCtrl', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const result = {}; if (msg.data.data['colorTemperature']) { result.color_temp = msg.data.data['colorTemperature']; } if (msg.data.data['colorMode']) { result.color_mode = msg.data.data['colorMode']; } if ( msg.data.data['currentX'] || msg.data.data['currentY'] || msg.data.data['currentSaturation'] || msg.data.data['enhancedCurrentHue'] ) { result.color = {}; if (msg.data.data['currentX']) { result.color.x = precisionRound(msg.data.data['currentX'] / 65535, 4); } if (msg.data.data['currentY']) { result.color.y = precisionRound(msg.data.data['currentY'] / 65535, 4); } if (msg.data.data['currentSaturation']) { result.color.saturation = precisionRound(msg.data.data['currentSaturation'] / 2.54, 1); } if (msg.data.data['enhancedCurrentHue']) { result.color.hue = precisionRound(msg.data.data['enhancedCurrentHue'] / (65535 / 360), 1); } } return result; }, }, WXKG11LM_click: { cid: 'genOnOff', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const data = msg.data.data; let clicks; if (data.onOff) { clicks = 1; } else if (data['32768']) { clicks = data['32768']; } if (clickLookup[clicks]) { return {click: clickLookup[clicks]}; } }, }, generic_illuminance: { cid: 'msIlluminanceMeasurement', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const illuminance = msg.data.data['measuredValue']; const calIlluminance = calibrateOptions(illuminance, options, 'illuminance'); // calibration value in +/- percent! return {illuminance: calIlluminance}; }, }, generic_pressure: { cid: 'msPressureMeasurement', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const pressure = parseFloat(msg.data.data['measuredValue']); const calPressure = calibrateOptions(pressure, options, 'pressure'); return {pressure: precisionRoundOptions(calPressure, options, 'pressure')}; }, }, WXKG02LM_click: { cid: 'genOnOff', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const ep = msg.endpoints[0]; return {click: getKey(model.ep(ep.device), ep.epId)}; }, }, WXKG02LM_click_multistate: { cid: 'genMultistateInput', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const ep = msg.endpoints[0]; const button = getKey(model.ep(ep.device), ep.epId); const value = msg.data.data['presentValue']; const actionLookup = { 0: 'long', 1: null, 2: 'double', }; const action = actionLookup[value]; if (button) { return {click: button + (action ? `_${action}` : '')}; } }, }, WXKG03LM_click: { cid: 'genOnOff', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { return {click: 'single'}; }, }, KEF1PA_arm: { cid: 'ssIasAce', type: 'cmdArm', convert: (model, msg, publish, options) => { const action = msg.data.data['armmode']; delete msg.data.data['armmode']; const modeLookup = { 0: 'home', 2: 'sleep', 3: 'away', }; return {action: modeLookup[action]}; }, }, KEF1PA_panic: { cid: 'ssIasAce', type: 'cmdPanic', convert: (model, msg, publish, options) => { delete msg.data.data['armmode']; return {action: 'panic'}; }, }, SJCGQ11LM_water_leak_iaszone: { cid: 'ssIasZone', type: 'statusChange', convert: (model, msg, publish, options) => { return {water_leak: msg.data.zoneStatus === 1}; }, }, SJCGQ11LM_water_leak_interval: { cid: 'genBasic', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data.hasOwnProperty('65281') && msg.data.data['65281'].hasOwnProperty('100')) { return {water_leak: msg.data.data['65281']['100'] === 1}; } }, }, state: { cid: 'genOnOff', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data.hasOwnProperty('onOff')) { return {state: msg.data.data['onOff'] === 1 ? 'ON' : 'OFF'}; } }, }, state_report: { cid: 'genOnOff', type: 'attReport', convert: (model, msg, publish, options) => { if (msg.data.data.hasOwnProperty('onOff')) { return {state: msg.data.data['onOff'] === 1 ? 'ON' : 'OFF'}; } }, }, state_change: { cid: 'genOnOff', type: 'devChange', convert: (model, msg, publish, options) => { if (msg.data.data.hasOwnProperty('onOff')) { return {state: msg.data.data['onOff'] === 1 ? 'ON' : 'OFF'}; } }, }, xiaomi_power: { cid: 'genAnalogInput', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { return {power: precisionRound(msg.data.data['presentValue'], 2)}; }, }, xiaomi_plug_state: { cid: 'genBasic', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data['65281']) { const data = msg.data.data['65281']; return { state: data['100'] === 1 ? 'ON' : 'OFF', power: precisionRound(data['152'], 2), voltage: precisionRound(data['150'] * 0.1, 1), consumption: precisionRound(data['149'], 2), temperature: precisionRoundOptions(data['3'], options, 'temperature'), }; } }, }, xiaomi_bulb_interval: { cid: 'genBasic', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data['65281']) { const data = msg.data.data['65281']; return { state: data['100'] === 1 ? 'ON' : 'OFF', brightness: data['101'], color_temp: data['102'], }; } }, }, QBKG11LM_power: { cid: 'genBasic', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data['65281']) { const data = msg.data.data['65281']; return { power: precisionRound(data['152'], 2), consumption: precisionRound(data['149'], 2), temperature: precisionRoundOptions(data['3'], options, 'temperature'), }; } }, }, QBKG12LM_LLKZMK11LM_power: { cid: 'genBasic', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data['65281']) { const data = msg.data.data['65281']; return { power: precisionRound(data['152'], 2), consumption: precisionRound(data['149'], 2), temperature: precisionRoundOptions(data['3'], options, 'temperature'), }; } }, }, QBKG04LM_QBKG11LM_state: { cid: 'genOnOff', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data['61440']) { return {state: msg.data.data['onOff'] === 1 ? 'ON' : 'OFF'}; } else { return {click: 'single'}; } }, }, QBKG04LM_buttons: { cid: 'genOnOff', type: 'devChange', convert: (model, msg, publish, options) => { if (msg.endpoints[0].epId == 4) { return {action: msg.data.data['onOff'] === 1 ? 'release' : 'hold'}; } }, }, QBKG04LM_QBKG11LM_operation_mode: { cid: 'genBasic', type: 'devChange', convert: (model, msg, publish, options) => { const mappingMode = { 0x12: 'control_relay', 0xFE: 'decoupled', }; const key = '65314'; if (msg.data.data.hasOwnProperty(key)) { const mode = mappingMode[msg.data.data[key]]; return {operation_mode: mode}; } }, }, QBKG03LM_QBKG12LM_LLKZMK11LM_state: { cid: 'genOnOff', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data['61440']) { const ep = msg.endpoints[0]; const key = `state_${getKey(model.ep(ep.device), ep.epId)}`; const payload = {}; payload[key] = msg.data.data['onOff'] === 1 ? 'ON' : 'OFF'; return payload; } else { const mapping = {4: 'left', 5: 'right', 6: 'both'}; const button = mapping[msg.endpoints[0].epId]; return {click: button}; } }, }, QBKG11LM_click: { cid: 'genMultistateInput', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if ([1, 2].includes(msg.data.data.presentValue)) { const times = {1: 'single', 2: 'double'}; return {click: times[msg.data.data.presentValue]}; } }, }, QBKG12LM_click: { cid: 'genMultistateInput', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if ([1, 2].includes(msg.data.data.presentValue)) { const mapping = {5: 'left', 6: 'right', 7: 'both'}; const times = {1: 'single', 2: 'double'}; const button = mapping[msg.endpoints[0].epId]; return {click: `${button}_${times[msg.data.data.presentValue]}`}; } }, }, QBKG03LM_buttons: { cid: 'genOnOff', type: 'devChange', convert: (model, msg, publish, options) => { const mapping = {4: 'left', 5: 'right'}; const button = mapping[msg.endpoints[0].epId]; if (button) { const payload = {}; payload[`button_${button}`] = msg.data.data['onOff'] === 1 ? 'release' : 'hold'; return payload; } }, }, QBKG03LM_QBKG12LM_operation_mode: { cid: 'genBasic', type: 'devChange', convert: (model, msg, publish, options) => { const mappingButton = { '65314': 'left', '65315': 'right', }; const mappingMode = { 0x12: 'control_left_relay', 0x22: 'control_right_relay', 0xFE: 'decoupled', }; for (const key in mappingButton) { if (msg.data.data.hasOwnProperty(key)) { const payload = {}; const mode = mappingMode[msg.data.data[key]]; payload[`operation_mode_${mappingButton[key]}`] = mode; return payload; } } }, }, xiaomi_lock_report: { cid: 'genBasic', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { if (msg.data.data['65328']) { const data = msg.data.data['65328']; const state = data.substr(2, 2); const action = data.substr(4, 2); const keynum = data.substr(6, 2); if (state == 11) { if (action == 1) { // unknown key return {keyerror: true, inserted: 'unknown'}; } if (action == 3) { // explicitly disabled key (i.e. reported lost) return {keyerror: true, inserted: keynum}; } if (action == 7) { // strange object introduced into the cylinder (e.g. a lock pick) return {keyerror: true, inserted: 'strange'}; } } if (state == 12) { if (action == 1) { return {inserted: keynum}; } if (action == 11) { return {forgotten: keynum}; } } } }, }, ZNCLDJ11LM_curtain_genAnalogOutput_change: { cid: 'genAnalogOutput', type: 'devChange', convert: (model, msg, publish, options) => { let running = false; if (msg.data.data['61440']) { running = msg.data.data['61440'] !== 0; } const position = precisionRound(msg.data.data['presentValue'], 2); return {position: position, running: running}; }, }, ZNCLDJ11LM_curtain_genAnalogOutput_report: { cid: 'genAnalogOutput', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { let running = false; if (msg.data.data['61440']) { running = msg.data.data['61440'] !== 0; } const position = precisionRound(msg.data.data['presentValue'], 2); return {position: position, running: running}; }, }, JTYJGD01LMBW_smoke: { cid: 'ssIasZone', type: 'statusChange', convert: (model, msg, publish, options) => { return {smoke: msg.data.zoneStatus === 1}; }, }, heiman_pir: { cid: 'ssIasZone', type: 'statusChange', convert: (model, msg, publish, options) => { const zoneStatus = msg.data.zoneStatus; return { occupancy: (zoneStatus & 1) > 0, // Bit 1 = Alarm: Motion detection tamper: (zoneStatus & 1<<2) > 0, // Bit 3 = Tamper status battery_low: (zoneStatus & 1<<3) > 0, // Bit 4 = Battery LOW indicator }; }, }, heiman_smoke: { cid: 'ssIasZone', type: 'statusChange', convert: (model, msg, publish, options) => { const zoneStatus = msg.data.zoneStatus; return { smoke: (zoneStatus & 1) > 0, // Bit 1 = Alarm: Smoke battery_low: (zoneStatus & 1<<3) > 0, // Bit 4 = Battery LOW indicator }; }, }, heiman_smart_controller_armmode: { cid: 'ssIasAce', type: 'cmdArm', convert: (model, msg, publish, options) => { if (msg.data.data.armmode != null) { const lookup = { 0: 'disarm', 1: 'arm_partial_zones', 3: 'arm_all_zones', }; const value = msg.data.data.armmode; return {action: lookup[value] || `armmode_${value}`}; } }, }, heiman_smart_controller_emergency: { cid: 'ssIasAce', type: 'cmdEmergency', convert: (model, msg, publish, options) => { return {action: 'emergency'}; }, }, battery_200: { cid: 'genPowerCfg', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const batt = msg.data.data.batteryPercentageRemaining; const battLow = msg.data.data.batteryAlarmState; const voltage = msg.data.data.batteryVoltage; const results = {}; if (batt != null) { const value = Math.round(batt/200.0*10000)/100; // Out of 200 results['battery'] = value; } if (battLow != null) { if (battLow) { results['battery_low'] = true; } else { results['battery_low'] = false; } } if (voltage != null) { results['voltage'] = voltage * 100; } return results; }, }, heiman_smoke_enrolled: { cid: 'ssIasZone', type: 'devChange', convert: (model, msg, publish, options) => { const zoneId = msg.data.data.zoneId; const zoneState = msg.data.data.zoneState; const results = {}; if (zoneState) { results['enrolled'] = true; } else { results['enrolled'] = false; } results['zone_id'] = zoneId; return results; }, }, generic_ias_statuschange_gas: { cid: 'ssIasZone', type: 'statusChange', convert: (model, msg, publish, options) => { const zoneStatus = msg.data.zoneStatus; return { gas: (zoneStatus & 1) > 0, // Bit 1 = Alarm: Gas tamper: (zoneStatus & 1<<2) > 0, // Bit 3 = Tamper status battery_low: (zoneStatus & 1<<3) > 0, // Bit 4 = Battery LOW indicator }; }, }, heiman_water_leak: { cid: 'ssIasZone', type: 'statusChange', convert: (model, msg, publish, options) => { const zoneStatus = msg.data.zoneStatus; return { water_leak: (zoneStatus & 1) > 0, // Bit 1 = Alarm: Water leak tamper: (zoneStatus & 1<<2) > 0, // Bit 3 = Tamper status battery_low: (zoneStatus & 1<<3) > 0, // Bit 4 = Battery LOW indicator }; }, }, heiman_contact: { cid: 'ssIasZone', type: 'statusChange', convert: (model, msg, publish, options) => { const zoneStatus = msg.data.zoneStatus; return { contact: (zoneStatus & 1) > 0, // Bit 1 = Alarm: Contact detection tamper: (zoneStatus & 1<<2) > 0, // Bit 3 = Tamper status battery_low: (zoneStatus & 1<<3) > 0, // Bit 4 = Battery LOW indicator }; }, }, heiman_carbon_monoxide: { cid: 'ssIasZone', type: 'statusChange', convert: (model, msg, publish, options) => { const zoneStatus = msg.data.zoneStatus; return { carbon_monoxide: (zoneStatus & 1) > 0, // Bit 1 = Alarm: Carbon monoxide battery_low: (zoneStatus & 1<<3) > 0, // Bit 4 = Battery LOW indicator }; }, }, JTQJBF01LMBW_gas: { cid: 'ssIasZone', type: 'statusChange', convert: (model, msg, publish, options) => { return {gas: msg.data.zoneStatus === 1}; }, }, JTQJBF01LMBW_gas_density: { cid: 'genBasic', type: ['attReport', 'readRsp'], convert: (model, msg, publish, options) => { const data = msg.data.data; if (data && data['65281']) { const basicAttrs = data['65281']; if (basicAttrs.hasOwnProperty('100')) { return {gas_density: basicAttrs['100']}; } } }, }, JTQJBF01LMBW_sensitivity: { cid: 'ssIasZone', type: 'devChange', convert: (model, msg, publish, options) => { const data = msg.data.data; const lookup = { '1': 'low', '2': 'medium', '3': 'high', }; if (data && data.hasOwnProperty('65520')) { const value = data['65520']; if (value && value.startsWith('0x020')) { return { sensitivity: lookup[value.charAt(5)], };