UNPKG

zigbee-herdsman-converters

Version:

Collection of device converters to be used with zigbee-herdsman

1,027 lines (948 loc) • 242 kB
'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,