zigbee-herdsman-converters
Version:
Collection of device converters to be used with zigbee-herdsman
1,027 lines (948 loc) • 242 kB
JavaScript
'use strict';
const globalStore = require('../lib/store');
const tuya = require('../lib/tuya');
const utils = require('../lib/utils');
const herdsman = require('zigbee-herdsman');
const legacy = require('../lib/legacy');
const light = require('../lib/light');
const constants = require('../lib/constants');
const libColor = require('../lib/color');
const manufacturerOptions = {
xiaomi: {manufacturerCode: herdsman.Zcl.ManufacturerCode.LUMI_UNITED_TECH, disableDefaultResponse: true},
osram: {manufacturerCode: herdsman.Zcl.ManufacturerCode.OSRAM},
eurotronic: {manufacturerCode: herdsman.Zcl.ManufacturerCode.JENNIC},
danfoss: {manufacturerCode: herdsman.Zcl.ManufacturerCode.DANFOSS},
hue: {manufacturerCode: herdsman.Zcl.ManufacturerCode.PHILIPS},
sinope: {manufacturerCode: herdsman.Zcl.ManufacturerCode.SINOPE_TECH},
/*
* Ubisys doesn't accept a manufacturerCode on some commands
* This bug has been reported, but it has not been fixed:
* https://github.com/Koenkk/zigbee-herdsman/issues/52
*/
ubisys: {manufacturerCode: herdsman.Zcl.ManufacturerCode.UBISYS},
ubisysNull: {manufacturerCode: null},
tint: {manufacturerCode: herdsman.Zcl.ManufacturerCode.MUELLER_LICHT_INT},
legrand: {manufacturerCode: herdsman.Zcl.ManufacturerCode.VANTAGE, disableDefaultResponse: true},
viessmann: {manufacturerCode: herdsman.Zcl.ManufacturerCode.VIESSMAN_ELEKTRO},
};
const converters = {
// #region Generic converters
read: {
key: ['read'],
convertSet: async (entity, key, value, meta) => {
const result = await entity.read(value.cluster, value.attributes, (value.hasOwnProperty('options') ? value.options : {}));
meta.logger.info(`Read result of '${value.cluster}': ${JSON.stringify(result)}`);
if (value.hasOwnProperty('state_property')) {
return {state: {[value.state_property]: result}};
}
},
},
write: {
key: ['write'],
convertSet: async (entity, key, value, meta) => {
const options = utils.getOptions(meta.mapped, entity);
if (value.hasOwnProperty('options')) {
Object.assign(options, value.options);
}
await entity.write(value.cluster, value.payload, options);
meta.logger.info(`Wrote '${JSON.stringify(value.payload)}' to '${value.cluster}'`);
},
},
factory_reset: {
key: ['reset'],
convertSet: async (entity, key, value, meta) => {
await entity.command('genBasic', 'resetFactDefault', {}, utils.getOptions(meta.mapped, entity));
},
},
arm_mode: {
key: ['arm_mode'],
convertSet: async (entity, key, value, meta) => {
const mode = utils.getKey(constants.armMode, value.mode, undefined, Number);
if (mode === undefined) {
throw new Error(`Unsupported mode: '${value.mode}', should be one of: ${Object.values(constants.armMode)}`);
}
if (value.hasOwnProperty('transaction')) {
entity.commandResponse('ssIasAce', 'armRsp', {armnotification: mode}, {}, value.transaction);
}
let panelStatus = mode;
if (meta.mapped.model === '3400-D') {
panelStatus = mode !== 0 && mode !== 4 ? 0x80: 0x00;
}
globalStore.putValue(entity, 'panelStatus', panelStatus);
const payload = {panelstatus: panelStatus, secondsremain: 0, audiblenotif: 0, alarmstatus: 0};
entity.commandResponse('ssIasAce', 'panelStatusChanged', payload);
},
},
power_on_behavior: {
key: ['power_on_behavior'],
convertSet: async (entity, key, value, meta) => {
value = value.toLowerCase();
const lookup = {'off': 0, 'on': 1, 'toggle': 2, 'previous': 255};
utils.validateValue(value, Object.keys(lookup));
await entity.write('genOnOff', {startUpOnOff: lookup[value]}, utils.getOptions(meta.mapped, entity));
return {state: {power_on_behavior: value}};
},
convertGet: async (entity, key, meta) => {
await entity.read('genOnOff', ['startUpOnOff']);
},
},
light_color_mode: {
key: ['color_mode'],
convertGet: async (entity, key, meta) => {
await entity.read('lightingColorCtrl', ['colorMode']);
},
},
light_color_options: {
key: ['color_options'],
convertSet: async (entity, key, value, meta) => {
const options = (value.hasOwnProperty('execute_if_off') && value.execute_if_off) ? 1 : 0;
await entity.write('lightingColorCtrl', {options}, utils.getOptions(meta.mapped, entity));
return {state: {'color_options': value}};
},
convertGet: async (entity, key, meta) => {
await entity.read('lightingColorCtrl', ['options']);
},
},
lock: {
key: ['state'],
convertSet: async (entity, key, value, meta) => {
await entity.command(
'closuresDoorLock',
`${value.toLowerCase()}Door`,
{'pincodevalue': ''},
utils.getOptions(meta.mapped, entity),
);
return {readAfterWriteTime: 200};
},
convertGet: async (entity, key, meta) => {
await entity.read('closuresDoorLock', ['lockState']);
},
},
lock_auto_relock_time: {
key: ['auto_relock_time'],
convertSet: async (entity, key, value, meta) => {
await entity.write('closuresDoorLock', {autoRelockTime: value}, utils.getOptions(meta.mapped, entity));
return {state: {auto_relock_time: value}};
},
convertGet: async (entity, key, meta) => {
await entity.read('closuresDoorLock', ['autoRelockTime']);
},
},
lock_sound_volume: {
key: ['sound_volume'],
convertSet: async (entity, key, value, meta) => {
utils.validateValue(value, constants.lockSoundVolume);
await entity.write('closuresDoorLock',
{soundVolume: constants.lockSoundVolume.indexOf(value)}, utils.getOptions(meta.mapped, entity));
return {state: {sound_volume: value}};
},
convertGet: async (entity, key, meta) => {
await entity.read('closuresDoorLock', ['soundVolume']);
},
},
pincode_lock: {
key: ['pin_code'],
convertSet: async (entity, key, value, meta) => {
const user = value.user;
const pinCode = value.pin_code;
if ( isNaN(user) ) {
throw new Error('user must be numbers');
}
if (!utils.isInRange(0, meta.mapped.meta.pinCodeCount - 1, user)) {
throw new Error('user must be in range for device');
}
if (pinCode === undefined || pinCode === null) {
await entity.command(
'closuresDoorLock',
'clearPinCode',
{
'userid': user,
},
utils.getOptions(meta.mapped),
);
} else {
if (isNaN(pinCode)) {
throw new Error('pinCode must be a number or pinCode');
}
await entity.command(
'closuresDoorLock',
'setPinCode',
{
'userid': user,
'userstatus': 1,
'usertype': 0,
'pincodevalue': pinCode.toString(),
},
utils.getOptions(meta.mapped),
);
}
},
convertGet: async (entity, key, meta) => {
const user = meta && meta.message && meta.message.pin_code ? meta.message.pin_code.user : undefined;
if (user === undefined) {
const max = meta.mapped.meta.pinCodeCount;
// Get all
const options = utils.getOptions(meta);
for (let i = 0; i < max; i++) {
await entity.command('closuresDoorLock', 'getPinCode', {userid: i}, options);
}
} else {
if (isNaN(user)) {
throw new Error('user must be numbers');
}
if (!utils.isInRange(0, meta.mapped.meta.pinCodeCount - 1, user)) {
throw new Error('userId must be in range for device');
}
await entity.command('closuresDoorLock', 'getPinCode', {userid: user}, utils.getOptions(meta));
}
},
},
lock_userstatus: {
key: ['user_status'],
convertSet: async (entity, key, value, meta) => {
const user = value.user;
if ( isNaN(user) ) {
throw new Error('user must be numbers');
}
if (!utils.isInRange(0, meta.mapped.meta.pinCodeCount - 1, user)) {
throw new Error('user must be in range for device');
}
const status = utils.getKey(constants.lockUserStatus, value.status, undefined, Number);
if (status === undefined) {
throw new Error(`Unsupported status: '${value.status}', should be one of: ${Object.values(constants.lockUserStatus)}`);
}
await entity.command(
'closuresDoorLock',
'setUserStatus',
{
'userid': user,
'userstatus': status,
},
utils.getOptions(meta.mapped),
);
},
convertGet: async (entity, key, meta) => {
const user = meta && meta.message && meta.message.user_status ? meta.message.user_status.user : undefined;
if (user === undefined) {
const max = meta.mapped.meta.pinCodeCount;
// Get all
const options = utils.getOptions(meta);
for (let i = 0; i < max; i++) {
await entity.command('closuresDoorLock', 'getUserStatus', {userid: i}, options);
}
} else {
if (isNaN(user)) {
throw new Error('user must be numbers');
}
if (!utils.isInRange(0, meta.mapped.meta.pinCodeCount - 1, user)) {
throw new Error('userId must be in range for device');
}
await entity.command('closuresDoorLock', 'getUserStatus', {userid: user}, utils.getOptions(meta));
}
},
},
on_off: {
key: ['state', 'on_time', 'off_wait_time'],
convertSet: async (entity, key, value, meta) => {
const state = meta.message.hasOwnProperty('state') ? meta.message.state.toLowerCase() : null;
utils.validateValue(state, ['toggle', 'off', 'on']);
if (state === 'on' && (meta.message.hasOwnProperty('on_time') || meta.message.hasOwnProperty('off_wait_time'))) {
const onTime = meta.message.hasOwnProperty('on_time') ? meta.message.on_time : 0;
const offWaitTime = meta.message.hasOwnProperty('off_wait_time') ? meta.message.off_wait_time : 0;
if (typeof onTime !== 'number') {
throw Error('The on_time value must be a number!');
}
if (typeof offWaitTime !== 'number') {
throw Error('The off_wait_time value must be a number!');
}
const payload = {ctrlbits: 0, ontime: Math.round(onTime * 10), offwaittime: Math.round(offWaitTime * 10)};
await entity.command('genOnOff', 'onWithTimedOff', payload, utils.getOptions(meta.mapped, entity));
} else {
await entity.command('genOnOff', state, {}, utils.getOptions(meta.mapped, entity));
if (state === 'toggle') {
const currentState = meta.state[`state${meta.endpoint_name ? `_${meta.endpoint_name}` : ''}`];
return currentState ? {state: {state: currentState === 'OFF' ? 'ON' : 'OFF'}} : {};
} else {
return {state: {state: state.toUpperCase()}};
}
}
},
convertGet: async (entity, key, meta) => {
await entity.read('genOnOff', ['onOff']);
},
},
cover_via_brightness: {
key: ['position', 'state'],
convertSet: async (entity, key, value, meta) => {
if (typeof value !== 'number') {
value = value.toLowerCase();
const lookup = {'open': 100, 'close': 0};
utils.validateValue(value, Object.keys(lookup));
value = lookup[value];
}
const invert = utils.getMetaValue(entity, meta.mapped, 'coverInverted', 'allEqual', false) ?
!meta.options.invert_cover : meta.options.invert_cover;
const position = invert ? 100 - value : value;
await entity.command(
'genLevelCtrl',
'moveToLevelWithOnOff',
{level: utils.mapNumberRange(Number(position), 0, 100, 0, 255).toString(), transtime: 0},
utils.getOptions(meta.mapped, entity),
);
return {state: {position: value}, readAfterWriteTime: 0};
},
convertGet: async (entity, key, meta) => {
await entity.read('genLevelCtrl', ['currentLevel']);
},
},
warning: {
key: ['warning'],
convertSet: async (entity, key, value, meta) => {
const mode = {'stop': 0, 'burglar': 1, 'fire': 2, 'emergency': 3, 'police_panic': 4, 'fire_panic': 5, 'emergency_panic': 6};
const level = {'low': 0, 'medium': 1, 'high': 2, 'very_high': 3};
const values = {
mode: value.mode || 'emergency',
level: value.level || 'medium',
strobe: value.hasOwnProperty('strobe') ? value.strobe : true,
duration: value.hasOwnProperty('duration') ? value.duration : 10,
};
const info = (mode[values.mode] << 4) + ((values.strobe ? 1 : 0) << 2) + (level[values.level]);
await entity.command(
'ssIasWd',
'startWarning',
{startwarninginfo: info, warningduration: values.duration},
utils.getOptions(meta.mapped, entity),
);
},
},
cover_state: {
key: ['state'],
convertSet: async (entity, key, value, meta) => {
const lookup = {'open': 'upOpen', 'close': 'downClose', 'stop': 'stop', 'on': 'upOpen', 'off': 'downClose'};
value = value.toLowerCase();
utils.validateValue(value, Object.keys(lookup));
await entity.command('closuresWindowCovering', lookup[value], {}, utils.getOptions(meta.mapped, entity));
},
},
cover_position_tilt: {
key: ['position', 'tilt'],
convertSet: async (entity, key, value, meta) => {
const isPosition = (key === 'position');
const invert = !(utils.getMetaValue(entity, meta.mapped, 'coverInverted', 'allEqual', false) ?
!meta.options.invert_cover : meta.options.invert_cover);
const position = invert ? 100 - value : value;
// Zigbee officially expects 'open' to be 0 and 'closed' to be 100 whereas
// HomeAssistant etc. work the other way round.
// For zigbee-herdsman-converters: open = 100, close = 0
await entity.command(
'closuresWindowCovering',
isPosition ? 'goToLiftPercentage' : 'goToTiltPercentage',
isPosition ? {percentageliftvalue: position} : {percentagetiltvalue: position},
utils.getOptions(meta.mapped, entity),
);
return {state: {[isPosition ? 'position' : 'tilt']: value}};
},
convertGet: async (entity, key, meta) => {
const isPosition = (key === 'position');
await entity.read('closuresWindowCovering', [isPosition ? 'currentPositionLiftPercentage' : 'currentPositionTiltPercentage']);
},
},
occupancy_timeout: {
// Sets delay after motion detector changes from occupied to unoccupied
key: ['occupancy_timeout'],
convertSet: async (entity, key, value, meta) => {
value *= 1;
await entity.write('msOccupancySensing', {pirOToUDelay: value}, utils.getOptions(meta.mapped, entity));
return {state: {occupancy_timeout: value}};
},
convertGet: async (entity, key, meta) => {
await entity.read('msOccupancySensing', ['pirOToUDelay']);
},
},
level_config: {
key: ['level_config'],
convertSet: async (entity, key, value, meta) => {
const state = {};
// parse payload to grab the keys
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
throw new Error('Payload is not valid JSON');
}
}
// onOffTransitionTime - range 0x0000 to 0xffff - optional
if (value.hasOwnProperty('on_off_transition_time')) {
let onOffTransitionTimeValue = Number(value.on_off_transition_time);
if (onOffTransitionTimeValue > 65535) onOffTransitionTimeValue = 65535;
if (onOffTransitionTimeValue < 0) onOffTransitionTimeValue = 0;
await entity.write('genLevelCtrl', {onOffTransitionTime: onOffTransitionTimeValue}, utils.getOptions(meta.mapped, entity));
Object.assign(state, {on_off_transition_time: onOffTransitionTimeValue});
}
// onTransitionTime - range 0x0000 to 0xffff - optional
// 0xffff = use onOffTransitionTime
if (value.hasOwnProperty('on_transition_time')) {
let onTransitionTimeValue = value.on_transition_time;
if (typeof onTransitionTimeValue === 'string' && onTransitionTimeValue.toLowerCase() == 'disabled') {
onTransitionTimeValue = 65535;
} else {
onTransitionTimeValue = Number(onTransitionTimeValue);
}
if (onTransitionTimeValue > 65535) onTransitionTimeValue = 65534;
if (onTransitionTimeValue < 0) onTransitionTimeValue = 0;
await entity.write('genLevelCtrl', {onTransitionTime: onTransitionTimeValue}, utils.getOptions(meta.mapped, entity));
// reverse translate number -> preset
if (onTransitionTimeValue == 65535) {
onTransitionTimeValue = 'disabled';
}
Object.assign(state, {on_transition_time: onTransitionTimeValue});
}
// offTransitionTime - range 0x0000 to 0xffff - optional
// 0xffff = use onOffTransitionTime
if (value.hasOwnProperty('off_transition_time')) {
let offTransitionTimeValue = value.off_transition_time;
if (typeof offTransitionTimeValue === 'string' && offTransitionTimeValue.toLowerCase() == 'disabled') {
offTransitionTimeValue = 65535;
} else {
offTransitionTimeValue = Number(offTransitionTimeValue);
}
if (offTransitionTimeValue > 65535) offTransitionTimeValue = 65534;
if (offTransitionTimeValue < 0) offTransitionTimeValue = 0;
await entity.write('genLevelCtrl', {offTransitionTime: offTransitionTimeValue}, utils.getOptions(meta.mapped, entity));
// reverse translate number -> preset
if (offTransitionTimeValue == 65535) {
offTransitionTimeValue = 'disabled';
}
Object.assign(state, {off_transition_time: offTransitionTimeValue});
}
// startUpCurrentLevel - range 0x00 to 0xff - optional
// 0x00 = return to minimum supported level
// 0xff = return to previous previous
if (value.hasOwnProperty('current_level_startup')) {
let startUpCurrentLevelValue = value.current_level_startup;
if (typeof startUpCurrentLevelValue === 'string' && startUpCurrentLevelValue.toLowerCase() == 'previous') {
startUpCurrentLevelValue = 255;
} else if (typeof startUpCurrentLevelValue === 'string' && startUpCurrentLevelValue.toLowerCase() == 'minimum') {
startUpCurrentLevelValue = 0;
} else {
startUpCurrentLevelValue = Number(startUpCurrentLevelValue);
}
if (startUpCurrentLevelValue > 255) startUpCurrentLevelValue = 254;
if (startUpCurrentLevelValue < 0) startUpCurrentLevelValue = 1;
await entity.write('genLevelCtrl', {startUpCurrentLevel: startUpCurrentLevelValue}, utils.getOptions(meta.mapped, entity));
// reverse translate number -> preset
if (startUpCurrentLevelValue == 255) {
startUpCurrentLevelValue = 'previous';
}
if (startUpCurrentLevelValue == 0) {
startUpCurrentLevelValue = 'minimum';
}
Object.assign(state, {current_level_startup: startUpCurrentLevelValue});
}
if (Object.keys(state).length > 0) {
return {state: {level_config: state}};
}
},
convertGet: async (entity, key, meta) => {
for (const attribute of ['onOffTransitionTime', 'onTransitionTime', 'offTransitionTime', 'startUpCurrentLevel']) {
try {
await entity.read('genLevelCtrl', [attribute]);
} catch (ex) {
// continue regardless of error, all these are optional in ZCL
}
}
},
},
ballast_config: {
key: ['ballast_config',
'ballast_physical_minimum_level',
'ballast_physical_maximum_level',
'ballast_minimum_level',
'ballast_maximum_level'],
// zcl attribute names are camel case, but we want to use snake case in the outside communication
convertSet: async (entity, key, value, meta) => {
if (key === 'ballast_config') {
value = utils.toCamelCase(value);
for (const [attrName, attrValue] of Object.entries(value)) {
const attributes = {};
attributes[attrName] = attrValue;
await entity.write('lightingBallastCfg', attributes);
}
}
if (key === 'ballast_minimum_level') {
await entity.write('lightingBallastCfg', {'minLevel': value});
}
if (key === 'ballast_maximum_level') {
await entity.write('lightingBallastCfg', {'maxLevel': value});
}
converters.ballast_config.convertGet(entity, key, meta);
},
convertGet: async (entity, key, meta) => {
let result = {};
for (const attrName of [
'physical_min_level',
'physical_max_level',
'ballast_status',
'min_level',
'max_level',
'power_on_level',
'power_on_fade_time',
'intrinsic_ballast_factor',
'ballast_factor_adjustment',
'lamp_quantity',
'lamp_type',
'lamp_manufacturer',
'lamp_rated_hours',
'lamp_burn_hours',
'lamp_alarm_mode',
'lamp_burn_hours_trip_point',
]) {
try {
result = {...result, ...(await entity.read('lightingBallastCfg', [utils.toCamelCase(attrName)]))};
} catch (ex) {
// continue regardless of error
}
}
if (key === 'ballast_config') {
meta.logger.warn(`ballast_config attribute results received: ${JSON.stringify(utils.toSnakeCase(result))}`);
}
},
},
light_brightness_step: {
key: ['brightness_step', 'brightness_step_onoff'],
convertSet: async (entity, key, value, meta) => {
const onOff = key.endsWith('_onoff');
const command = onOff ? 'stepWithOnOff' : 'step';
value = Number(value);
if (isNaN(value)) {
throw new Error(`${key} value of message: '${JSON.stringify(meta.message)}' invalid`);
}
const mode = value > 0 ? 0 : 1;
const transition = utils.getTransition(entity, key, meta).time;
const payload = {stepmode: mode, stepsize: Math.abs(value), transtime: transition};
await entity.command('genLevelCtrl', command, payload, utils.getOptions(meta.mapped, entity));
if (meta.state.hasOwnProperty('brightness')) {
let brightness = onOff || meta.state.state === 'ON' ? meta.state.brightness + value : meta.state.brightness;
if (value === 0) {
const entityToRead = utils.getEntityOrFirstGroupMember(entity);
if (entityToRead) {
brightness = (await entityToRead.read('genLevelCtrl', ['currentLevel'])).currentLevel;
}
}
brightness = Math.min(254, brightness);
brightness = Math.max(onOff || meta.state.state === 'OFF' ? 0 : 1, brightness);
if (utils.getMetaValue(entity, meta.mapped, 'turnsOffAtBrightness1', 'allEqual', false)) {
if (onOff && value < 0 && brightness === 1) {
brightness = 0;
} else if (onOff && value > 0 && meta.state.brightness === 0) {
brightness++;
}
}
return {state: {brightness, state: brightness === 0 ? 'OFF' : 'ON'}};
}
},
},
light_brightness_move: {
key: ['brightness_move', 'brightness_move_onoff'],
convertSet: async (entity, key, value, meta) => {
if (value === 'stop' || value === 0) {
await entity.command('genLevelCtrl', 'stop', {}, utils.getOptions(meta.mapped, entity));
// As we cannot determine the new brightness state, we read it from the device
await utils.sleep(500);
const target = utils.getEntityOrFirstGroupMember(entity);
const onOff = (await target.read('genOnOff', ['onOff'])).onOff;
const brightness = (await target.read('genLevelCtrl', ['currentLevel'])).currentLevel;
return {state: {brightness, state: onOff === 1 ? 'ON' : 'OFF'}};
} else {
value = Number(value);
if (isNaN(value)) {
throw new Error(`${key} value of message: '${JSON.stringify(meta.message)}' invalid`);
}
const payload = {movemode: value > 0 ? 0 : 1, rate: Math.abs(value)};
const command = key.endsWith('onoff') ? 'moveWithOnOff' : 'move';
await entity.command('genLevelCtrl', command, payload, utils.getOptions(meta.mapped, entity));
}
},
},
light_colortemp_step: {
key: ['color_temp_step'],
convertSet: async (entity, key, value, meta) => {
value = Number(value);
if (isNaN(value)) {
throw new Error(`${key} value of message: '${JSON.stringify(meta.message)}' invalid`);
}
const mode = value > 0 ? 1 : 3;
const transition = utils.getTransition(entity, key, meta).time;
const payload = {stepmode: mode, stepsize: Math.abs(value), transtime: transition, minimum: 0, maximum: 600};
await entity.command('lightingColorCtrl', 'stepColorTemp', payload, utils.getOptions(meta.mapped, entity));
// We cannot determine the color temperature from the current state so we read it, because
// - We don't know the max/min valus
// - Color mode could have been swithed (x/y or hue/saturation)
const entityToRead = utils.getEntityOrFirstGroupMember(entity);
if (entityToRead) {
await utils.sleep(100 + (transition * 100));
await entityToRead.read('lightingColorCtrl', ['colorTemperature']);
}
},
},
light_colortemp_move: {
key: ['colortemp_move', 'color_temp_move'],
convertSet: async (entity, key, value, meta) => {
if (key === 'color_temp_move' && (value === 'stop' || !isNaN(value))) {
value = value === 'stop' ? value : Number(value);
const payload = {minimum: 0, maximum: 600};
if (value === 'stop' || value === 0) {
payload.rate = 1;
payload.movemode = 0;
} else {
payload.rate = Math.abs(value);
payload.movemode = value > 0 ? 1 : 3;
}
await entity.command('lightingColorCtrl', 'moveColorTemp', payload, utils.getOptions(meta.mapped, entity));
// We cannot determine the color temperaturefrom the current state so we read it, because
// - Color mode could have been swithed (x/y or colortemp)
if (value === 'stop' || value === 0) {
const entityToRead = utils.getEntityOrFirstGroupMember(entity);
if (entityToRead) {
await utils.sleep(100);
await entityToRead.read('lightingColorCtrl', ['colorTemperature', 'colorMode']);
}
}
} else {
// Deprecated
const payload = {minimum: 153, maximum: 370, rate: 55};
const stop = (val) => ['stop', 'release', '0'].some((el) => val.includes(el));
const up = (val) => ['1', 'up'].some((el) => val.includes(el));
const arr = [value.toString()];
const moverate = meta.message.hasOwnProperty('rate') ? parseInt(meta.message.rate) : 55;
payload.rate = moverate;
if (arr.filter(stop).length) {
payload.movemode = 0;
} else {
payload.movemode=arr.filter(up).length ? 1 : 3;
}
await entity.command('lightingColorCtrl', 'moveColorTemp', payload, utils.getOptions(meta.mapped, entity));
}
},
},
light_hue_saturation_step: {
key: ['hue_step', 'saturation_step'],
convertSet: async (entity, key, value, meta) => {
value = Number(value);
if (isNaN(value)) {
throw new Error(`${key} value of message: '${JSON.stringify(meta.message)}' invalid`);
}
const command = key === 'hue_step' ? 'stepHue' : 'stepSaturation';
const attribute = key === 'hue_step' ? 'currentHue' : 'currentSaturation';
const mode = value > 0 ? 1 : 3;
const transition = utils.getTransition(entity, key, meta).time;
const payload = {stepmode: mode, stepsize: Math.abs(value), transtime: transition};
await entity.command('lightingColorCtrl', command, payload, utils.getOptions(meta.mapped, entity));
// We cannot determine the hue/saturation from the current state so we read it, because
// - Color mode could have been swithed (x/y or colortemp)
const entityToRead = utils.getEntityOrFirstGroupMember(entity);
if (entityToRead) {
await utils.sleep(100 + (transition * 100));
await entityToRead.read('lightingColorCtrl', [attribute, 'colorMode']);
}
},
},
light_hue_saturation_move: {
key: ['hue_move', 'saturation_move'],
convertSet: async (entity, key, value, meta) => {
value = value === 'stop' ? value : Number(value);
if (isNaN(value) && value !== 'stop') {
throw new Error(`${key} value of message: '${JSON.stringify(meta.message)}' invalid`);
}
const command = key === 'hue_move' ? 'moveHue' : 'moveSaturation';
const attribute = key === 'hue_move' ? 'currentHue' : 'currentSaturation';
const payload = {};
if (value === 'stop' || value === 0) {
payload.rate = 1;
payload.movemode = 0;
} else {
payload.rate = Math.abs(value);
payload.movemode = value > 0 ? 1 : 3;
}
await entity.command('lightingColorCtrl', command, payload, utils.getOptions(meta.mapped, entity));
// We cannot determine the hue/saturation from the current state so we read it, because
// - Color mode could have been swithed (x/y or colortemp)
if (value === 'stop' || value === 0) {
const entityToRead = utils.getEntityOrFirstGroupMember(entity);
if (entityToRead) {
await utils.sleep(100);
await entityToRead.read('lightingColorCtrl', [attribute, 'colorMode']);
}
}
},
},
light_onoff_brightness: {
key: ['state', 'brightness', 'brightness_percent'],
convertSet: async (entity, key, value, meta) => {
const {message} = meta;
const transition = utils.getTransition(entity, 'brightness', meta);
const turnsOffAtBrightness1 = utils.getMetaValue(entity, meta.mapped, 'turnsOffAtBrightness1', 'allEqual', false);
const state = message.hasOwnProperty('state') ? message.state.toLowerCase() : undefined;
let brightness = undefined;
if (message.hasOwnProperty('brightness')) {
brightness = Number(message.brightness);
} else if (message.hasOwnProperty('brightness_percent')) {
brightness = utils.mapNumberRange(Number(message.brightness_percent), 0, 100, 0, 255);
}
if (brightness !== undefined && (isNaN(brightness) || brightness < 0 || brightness > 255)) {
// Allow 255 value, changing this to 254 would be a breaking change.
throw new Error(`Brightness value of message: '${JSON.stringify(message)}' invalid, must be a number >= 0 and =< 254`);
}
if (state !== undefined && ['on', 'off', 'toggle'].includes(state) === false) {
throw new Error(`State value of message: '${JSON.stringify(message)}' invalid, must be 'ON', 'OFF' or 'TOGGLE'`);
}
if (state === 'toggle' || state === 'off' || (brightness === undefined && state === 'on')) {
if (transition.specified && (state === 'off' || state === 'on')) {
if (state === 'off' && meta.state.brightness && meta.state.state === 'ON') {
// https://github.com/Koenkk/zigbee2mqtt/issues/2850#issuecomment-580365633
// We need to remember the state before turning the device off as we need to restore
// it once we turn it on again.
// We cannot rely on the meta.state as when reporting is enabled the bulb will reports
// it brightness while decreasing the brightness.
globalStore.putValue(entity, 'brightness', meta.state.brightness);
globalStore.putValue(entity, 'turnedOffWithTransition', true);
}
const fallbackLevel = utils.getObjectProperty(meta.state, 'brightness', 254);
let level = state === 'off' ? 0 : globalStore.getValue(entity, 'brightness', fallbackLevel);
if (state === 'on' && level === 0) level = turnsOffAtBrightness1 ? 2 : 1;
const payload = {level, transtime: transition.time};
await entity.command('genLevelCtrl', 'moveToLevelWithOnOff', payload, utils.getOptions(meta.mapped, entity));
const result = {state: {state: state.toUpperCase()}};
if (state === 'on') result.state.brightness = level;
return result;
} else {
if (state === 'on' && globalStore.getValue(entity, 'turnedOffWithTransition') === true) {
/**
* In case the bulb it turned OFF with a transition and turned ON WITHOUT
* a transition, the brightness is not recovered as it turns on with brightness 1.
* https://github.com/Koenkk/zigbee-herdsman-converters/issues/1073
*/
globalStore.putValue(entity, 'turnedOffWithTransition', false);
await entity.command(
'genLevelCtrl',
'moveToLevelWithOnOff',
{level: globalStore.getValue(entity, 'brightness'), transtime: 0},
utils.getOptions(meta.mapped, entity),
);
return {state: {state: 'ON'}, readAfterWriteTime: transition * 100};
} else {
// Store brightness where the bulb was turned off with as we need it when the bulb is turned on
// with transition.
if (meta.state.hasOwnProperty('brightness') && state === 'off') {
globalStore.putValue(entity, 'brightness', meta.state.brightness);
globalStore.putValue(entity, 'turnedOffWithTransition', false);
}
const result = await converters.on_off.convertSet(entity, 'state', state, meta);
result.readAfterWriteTime = 0;
if (result.state && result.state.state === 'ON' && meta.state.brightness === 0) {
result.state.brightness = 1;
}
return result;
}
}
} else {
brightness = Math.min(254, brightness);
if (brightness === 1 && turnsOffAtBrightness1) {
brightness = 2;
}
globalStore.putValue(entity, 'brightness', brightness);
await entity.command(
'genLevelCtrl',
'moveToLevelWithOnOff',
{level: Number(brightness), transtime: transition.time},
utils.getOptions(meta.mapped, entity),
);
// If this command is send to a group, and this group contains a device not supporting genLevelCtrl, e.g. a switch
// that device won't change state with the moveToLevelWithOnOff command.
// Therefore send the genOnOff command also.
// https://github.com/Koenkk/zigbee2mqtt/issues/4558
if (entity.constructor.name === 'Group' && state !== undefined && transition.time === 0) {
if (entity.members.filter((e) => !e.supportsInputCluster('genLevelCtrl')).length !== 0) {
await converters.on_off.convertSet(entity, 'state', 'ON', meta);
}
}
return {
state: {state: brightness === 0 ? 'OFF' : 'ON', brightness: Number(brightness)},
readAfterWriteTime: transition.time * 100,
};
}
},
convertGet: async (entity, key, meta) => {
if (key === 'brightness') {
await entity.read('genLevelCtrl', ['currentLevel']);
} else if (key === 'state') {
await converters.on_off.convertGet(entity, key, meta);
}
},
},
light_colortemp: {
key: ['color_temp', 'color_temp_percent'],
convertSet: async (entity, key, value, meta) => {
const [colorTempMin, colorTempMax] = light.findColorTempRange(entity, meta.logger);
const preset = {'warmest': colorTempMax, 'warm': 454, 'neutral': 370, 'cool': 250, 'coolest': colorTempMin};
if (key === 'color_temp_percent') {
value = utils.mapNumberRange(value,
0, 100,
((colorTempMin != null) ? colorTempMin : 154), ((colorTempMax != null) ? colorTempMax : 500),
).toString();
}
if (typeof value === 'string' && isNaN(value)) {
if (value.toLowerCase() in preset) {
value = preset[value.toLowerCase()];
} else {
throw new Error(`Unknown preset '${value}'`);
}
}
value = Number(value);
// ensure value within range
value = light.clampColorTemp(value, colorTempMin, colorTempMax, meta.logger);
const payload = {colortemp: value, transtime: utils.getTransition(entity, key, meta).time};
await entity.command('lightingColorCtrl', 'moveToColorTemp', payload, utils.getOptions(meta.mapped, entity));
return {state: libColor.syncColorState({'color_mode': constants.colorMode[2], 'color_temp': value}, meta.state, meta.options),
readAfterWriteTime: payload.transtime * 100};
},
convertGet: async (entity, key, meta) => {
await entity.read('lightingColorCtrl', ['colorMode', 'colorTemperature']);
},
},
light_colortemp_startup: {
key: ['color_temp_startup'],
convertSet: async (entity, key, value, meta) => {
const [colorTempMin, colorTempMax] = light.findColorTempRange(entity, meta.logger);
const preset = {'warmest': colorTempMax, 'warm': 454, 'neutral': 370, 'cool': 250, 'coolest': colorTempMin, 'previous': 65535};
if (typeof value === 'string' && isNaN(value)) {
if (value.toLowerCase() in preset) {
value = preset[value.toLowerCase()];
} else {
throw new Error(`Unknown preset '${value}'`);
}
}
value = Number(value);
// ensure value within range
// we do allow one exception for 0xffff, which is to restore the previous value
if (value != 65535) {
value = light.clampColorTemp(value, colorTempMin, colorTempMax, meta.logger);
}
await entity.write('lightingColorCtrl', {startUpColorTemperature: value}, utils.getOptions(meta.mapped, entity));
return {state: {color_temp_startup: value}};
},
convertGet: async (entity, key, meta) => {
await entity.read('lightingColorCtrl', ['startUpColorTemperature']);
},
},
light_color: {
key: ['color'],
convertSet: async (entity, key, value, meta) => {
let command;
const newColor = libColor.Color.fromConverterArg(value);
const newState = {};
const zclData = {transtime: utils.getTransition(entity, key, meta).time};
if (newColor.isRGB() || newColor.isXY()) {
// Convert RGB to XY color mode because Zigbee doesn't support RGB (only x/y and hue/saturation)
const xy = newColor.isRGB() ? newColor.rgb.gammaCorrected().toXY().rounded(4) : newColor.xy;
// Some bulbs e.g. RB 185 C don't turn to red (they don't respond at all) when x: 0.701 and y: 0.299
// is send. These values are e.g. send by Home Assistant when clicking red in the color wheel.
// If we slighlty modify these values the bulb will respond.
// https://github.com/home-assistant/home-assistant/issues/31094
if (utils.getMetaValue(entity, meta.mapped, 'applyRedFix', 'allEqual', false) && xy.x == 0.701 && xy.y === 0.299) {
xy.x = 0.7006;
xy.y = 0.2993;
}
newState.color_mode = constants.colorMode[1];
newState.color = xy.toObject();
zclData.colorx = utils.mapNumberRange(xy.x, 0, 1, 0, 65535);
zclData.colory = utils.mapNumberRange(xy.y, 0, 1, 0, 65535);
command = 'moveToColor';
} else if (newColor.isHSV()) {
const enhancedHue = utils.getMetaValue(entity, meta.mapped, 'enhancedHue', 'allEqual', true);
const hsv = newColor.hsv;
const hsvCorrected = hsv.colorCorrected(meta);
newState.color_mode = constants.colorMode[0];
newState.color = hsv.toObject(false);
if (hsv.hue !== null) {
if (enhancedHue) {
zclData.enhancehue = utils.mapNumberRange(hsvCorrected.hue, 0, 360, 0, 65535);
} else {
zclData.hue = utils.mapNumberRange(hsvCorrected.hue, 0, 360, 0, 254);
}
zclData.direction = value.direction || 0;
}
if (hsv.saturation != null) {
zclData.saturation = utils.mapNumberRange(hsvCorrected.saturation, 0, 100, 0, 254);
}
if (hsv.value !== null) {
// fallthrough to genLevelCtrl
value.brightness = utils.mapNumberRange(hsvCorrected.value, 0, 100, 0, 254);
}
if (hsv.hue !== null && hsv.saturation !== null) {
if (enhancedHue) {
command = 'enhancedMoveToHueAndSaturation';
} else {
command = 'moveToHueAndSaturation';
}
} else if (hsv.hue !== null) {
if (enhancedHue) {
command = 'enhancedMoveToHue';
} else {
command = 'moveToHue';
}
} else if (hsv.saturation !== null) {
command = 'moveToSaturation';
}
}
if (value.hasOwnProperty('brightness')) {
await entity.command(
'genLevelCtrl',
'moveToLevelWithOnOff',
{level: Number(value.brightness), transtime: utils.getTransition(entity, key, meta).time},
utils.getOptions(meta.mapped, entity),
);
}
await entity.command('lightingColorCtrl', command, zclData, utils.getOptions(meta.mapped, entity));
return {state: libColor.syncColorState(newState, meta.state, meta.options), readAfterWriteTime: zclData.transtime * 100};
},
convertGet: async (entity, key, meta) => {
await entity.read('lightingColorCtrl', light.readColorAttributes(entity, meta));
},
},
light_color_colortemp: {
/**
* This converter is a combination of light_color and light_colortemp and
* can be used instead of the two individual converters. When used to set,
* it actually calls out to light_color or light_colortemp to get the
* return value. When used to get, it gets both color and colorTemp in
* one call.
* The reason for the existence of this somewhat peculiar converter is
* that some lights don't report their state when changed. To fix this,
* we query the state after we set it. We want to query color and colorTemp
* both when setting either, because both change when setting one. This
* converter is used to do just that.
*/
key: ['color', 'color_temp', 'color_temp_percent'],
convertSet: async (entity, key, value, meta) => {
if (key == 'color') {
const result = await converters.light_color.convertSet(entity, key, value, meta);
return result;
} else if (key == 'color_temp' || key == 'color_temp_percent') {
const result = await converters.light_colortemp.convertSet(entity, key, value, meta);
return result;
}
},
convertGet: async (entity, key, meta) => {
await entity.read('lightingColorCtrl', light.readColorAttributes(entity, meta, ['colorTemperature']));
},
},
effect: {
key: ['effect', 'alert', 'flash'], // alert and flash are deprecated.
convertSet: async (entity, key, value, meta) => {
if (key === 'effect') {
const lookup = {blink: 0, breathe: 1, okay: 2, channel_change: 11, finish_effect: 254, stop_effect: 255};
value = value.toLowerCase();
utils.validateValue(value, Object.keys(lookup));
const payload = {effectid: lookup[value], effectvariant: 0};
await entity.command('genIdentify', 'triggerEffect', payload, utils.getOptions(meta.mapped,