zigbee-shepherd-converters
Version:
Collection of device converters to be used with zigbee-shepherd
1,380 lines (1,295 loc) • 128 kB
JavaScript
'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)],
};