UNPKG

zigbee-herdsman-converters

Version:

Collection of device converters to be used with zigbee-herdsman

1,053 lines • 1.24 MB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.definitions = void 0; const node_util_1 = require("node:util"); const zigbee_herdsman_1 = require("zigbee-herdsman"); const zcl_1 = require("zigbee-herdsman/dist/zspec/zcl"); const fz = __importStar(require("../converters/fromZigbee")); const tz = __importStar(require("../converters/toZigbee")); const libColor = __importStar(require("../lib/color")); const constants_1 = require("../lib/constants"); const exposes = __importStar(require("../lib/exposes")); const legacy = __importStar(require("../lib/legacy")); const logger_1 = require("../lib/logger"); const m = __importStar(require("../lib/modernExtend")); const reporting = __importStar(require("../lib/reporting")); const globalStore = __importStar(require("../lib/store")); const tuya = __importStar(require("../lib/tuya")); const utils = __importStar(require("../lib/utils")); const utils_1 = require("../lib/utils"); const zosung = __importStar(require("../lib/zosung")); const NS = "zhc:tuya"; const { tuyaLight, tuyaBase, tuyaMagicPacket, dpBinary, dpNumeric, dpEnumLookup, tuyaWeatherForecast } = tuya.modernExtend; const e = exposes.presets; const ea = exposes.access; const fzZosung = zosung.fzZosung; const tzZosung = zosung.tzZosung; const ez = zosung.presetsZosung; const storeLocal = { getPrivatePJ1203A: (device) => { let priv = globalStore.getValue(device, "private_state"); if (priv === undefined) { // // The PJ-1203A is sending quick sequences of messages containing a single datapoint. // A sequence occurs every `update_frequency` seconds (10s by default) // // A typical sequence is composed of two identical groups for channel a and b. // // 102 energy_flow_a // 112 voltage // 113 current_a // 101 power_a // 110 power_factor_a // 111 ac_frequency // 115 power_ab // --- // 104 energy_flow_b // 112 voltage // 114 current_b // 105 power_b // 121 power_factor_b // 111 ac_frequency // 115 power_ab // // It should be noted that when no current is detected on channel x then // energy_flow_x is not emitted and current_x==0, power_x==0 and power_factor_x==100. // // The other datapoints are emitted every few minutes. // // There is a known issue on the _TZE204_81yrt3lo (with appVersion 74, stackVersion 0 and hwVersion 1). // The energy_flow datapoints are (incorrectly) emitted during the next update. This is quite problematic // because that means that the direction can be inverted for up to update_frequency seconds. // // The features implemented here are // - cache the datapoints for each channel and publish them together. // - (OPTIONAL) solve the issue described above by waiting for the next energy flow datapoint // before publishing the cached channel data. // - (OPTIONAL) provide signed power instead of energy flow. // - detect missing or reordered Zigbee message using the Tuya 'seq' attribute and invalidate // cached data accordingly. // priv = { // Cached values for both channels sign_a: null, sign_b: null, power_a: null, power_b: null, current_a: null, current_b: null, power_factor_a: null, power_factor_b: null, timestamp_a: null, timestamp_b: null, // Used to detect missing or misordered messages. last_seq: -99999, // Do all PJ-1203A increment seq by 256? If not, then this is // the value that will have to be customized. seq_inc: 256, // Also need to save the last published SIGNED values of // power_a and power_b to recompute power_ab on the fly. pub_power_a: null, pub_power_b: null, recompute_power_ab: function (result) { let modified = false; if ("power_a" in result) { this.pub_power_a = result.power_a * (result.energy_flow_a === "producing" ? -1 : 1); modified = true; } if ("power_b" in result) { this.pub_power_b = result.power_b * (result.energy_flow_b === "producing" ? -1 : 1); modified = true; } if (modified) { if (this.pub_power_a !== null && this.pub_power_b !== null) { // Cancel and reapply the scaling by 10 to avoid floating-point rounding errors // such as 79.8 - 37.1 = 42.699999999999996 result.power_ab = Math.round(10 * this.pub_power_a + 10 * this.pub_power_b) / 10; } } }, flush: function (result, channel, options) { const sign = this[`sign_${channel}`]; const power = this[`power_${channel}`]; const current = this[`current_${channel}`]; const powerFactor = this[`power_factor_${channel}`]; this[`sign_${channel}`] = this[`power_${channel}`] = this[`current_${channel}`] = this[`power_factor_${channel}`] = null; // Only publish if the set is complete otherwise discard everything. if (sign !== null && power !== null && current !== null && powerFactor !== null) { const signedPowerKey = `signed_power_${channel}`; const invertedEnergyFlowKey = `invert_energy_flow_${channel}`; const signedPower = options[signedPowerKey] != null ? options[signedPowerKey] : false; const invertedEnergyFlow = options[invertedEnergyFlowKey] != null ? options[invertedEnergyFlowKey] : false; if (signedPower) { result[`power_${channel}`] = sign * power; result[`energy_flow_${channel}`] = "sign"; } else { result[`power_${channel}`] = power; result[`energy_flow_${channel}`] = sign * (invertedEnergyFlow ? -1 : 1) >= 0 ? "consuming" : "producing"; } result[`timestamp_${channel}`] = this[`timestamp_${channel}`]; result[`current_${channel}`] = current; result[`power_factor_${channel}`] = powerFactor; this.recompute_power_ab(result); return true; } return false; }, // When the device does not detect any flow, it stops sending // the energy_flow datapoint (102 and 104) and always set // current_x=0, power_x=0 and power_factor_x=100. // // So if we see a datapoint with current==0 or power==0 // then we can safely assume that we are in that zero energy state. // // Also, the publication of a zero energy state is not delayed // when option late_energy_flow_a|b is set. flushZero: function (result, channel, options) { this[`sign_${channel}`] = +1; this[`power_${channel}`] = 0; this[`timestamp_${channel}`] = new Date().toISOString(); this[`current_${channel}`] = 0; this[`power_factor_${channel}`] = 100; this.flush(result, channel, options); }, // Some times the device sends a single zero value (either power or current). // This is most likely a glitch. We flush all values but set them to null // to indicate that they are not valid. // flushNull: function (result, channel, options) { this[`sign_${channel}`] = null; this[`power_${channel}`] = null; this[`current_${channel}`] = null; this[`power_factor_${channel}`] = null; this.flush(result, channel, options); }, clear: () => { priv.sign_a = null; priv.sign_b = null; priv.power_a = null; priv.power_b = null; priv.current_a = null; priv.current_b = null; priv.power_factor_a = null; priv.power_factor_b = null; // Used to detect single zero values priv.zero_power_a = null; priv.zero_power_b = null; priv.zero_current_a = null; priv.zero_current_b = null; }, }; globalStore.putValue(device, "private_state", priv); } return priv; }, }; const convLocal = { energyFlowPJ1203A: (channel) => { return { from: (v, meta, options) => { const priv = storeLocal.getPrivatePJ1203A(meta.device); const result = {}; priv[`sign_${channel}`] = v === 1 ? -1 : +1; const lateEnergyFlowKey = `late_energy_flow_${channel}`; const lateEnergyFlow = options[lateEnergyFlowKey] != null ? options[lateEnergyFlowKey] : false; if (lateEnergyFlow) { priv.flush(result, channel, options); } return result; }, }; }, powerPJ1203A: (channel) => { return { from: (v, meta, options) => { const priv = storeLocal.getPrivatePJ1203A(meta.device); const result = {}; priv[`power_${channel}`] = v / 10; priv[`timestamp_${channel}`] = new Date().toISOString(); if (v === 0) { const singleZeroRemoveKey = "single_zero_remove"; const singleZeroRemove = options[singleZeroRemoveKey] != null ? options[singleZeroRemoveKey] : false; if (singleZeroRemove && !priv[`zero_power_${channel}`]) { logger_1.logger.info("[PJ1203A] power is zero, flushing one time", NS); priv.flushNull(result, channel, options); } else { priv.flushZero(result, channel, options); } priv[`zero_power_${channel}`] = true; } else { priv[`zero_power_${channel}`] = false; } return result; }, }; }, currentPJ1203A: (channel) => { return { from: (v, meta, options) => { const priv = storeLocal.getPrivatePJ1203A(meta.device); const result = {}; priv[`current_${channel}`] = v / 1000; if (v === 0) { const singleZeroRemoveKey = "single_zero_remove"; const singleZeroRemove = options[singleZeroRemoveKey] != null ? options[singleZeroRemoveKey] : false; if (singleZeroRemove && !priv[`zero_current_${channel}`]) { logger_1.logger.info("[PJ1203A] current is zero, flushing one time", NS); priv.flushNull(result, channel, options); } else { priv.flushZero(result, channel, options); } priv[`zero_current_${channel}`] = true; } else { priv[`zero_current_${channel}`] = false; } return result; }, }; }, powerFactorPJ1203A: (channel) => { return { from: (v, meta, options) => { const priv = storeLocal.getPrivatePJ1203A(meta.device); const result = {}; priv[`power_factor_${channel}`] = v; const lateEnergyFlowKey = `late_energy_flow_${channel}`; const lateEnergyFlow = options[lateEnergyFlowKey] != null ? options[lateEnergyFlowKey] : false; if (!lateEnergyFlow) { priv.flush(result, channel, options); } return result; }, }; }, powerAbPJ1203A: () => { return { // power_ab datapoint is broken and will be recomputed so ignore it. from: (v, meta, options) => { return {}; }, }; }, energyForwardPJ1203A: (channel) => { return { from: (v, meta, options) => { const invertedEnergyFlowKey = `invert_energy_flow_${channel}`; const result_key = options[invertedEnergyFlowKey] ? `energy_produced_${channel}` : `energy_${channel}`; const result = {}; result[result_key] = v / 100; return result; }, }; }, energyReversePJ1203A: (channel) => { return { from: (v, meta, options) => { const invertedEnergyFlowKey = `invert_energy_flow_${channel}`; const result_key = options[invertedEnergyFlowKey] ? `energy_${channel}` : `energy_produced_${channel}`; const result = {}; result[result_key] = v / 100; return result; }, }; }, sceneCubeAction: () => { const lookup = ["side_1", "side_2", "side_3", "side_4", "knock", "shake"]; const expose = e.action(lookup); return [ tuya.modernExtend.dpEnumLookup({ dp: 0x01, name: "action", type: tuya.dataTypes.bool, readOnly: true, expose: expose, lookup: { side_1: 0 }, }), tuya.modernExtend.dpEnumLookup({ dp: 0x02, name: "action", type: tuya.dataTypes.bool, readOnly: true, expose: expose, lookup: { side_2: 0 }, }), tuya.modernExtend.dpEnumLookup({ dp: 0x03, name: "action", type: tuya.dataTypes.bool, readOnly: true, expose: expose, lookup: { side_3: 0 }, }), tuya.modernExtend.dpEnumLookup({ dp: 0x04, name: "action", type: tuya.dataTypes.bool, readOnly: true, expose: expose, lookup: { side_4: 0 }, }), tuya.modernExtend.dpEnumLookup({ dp: 0x05, name: "action", type: tuya.dataTypes.bool, readOnly: true, expose: expose, lookup: { knock: 0 }, }), tuya.modernExtend.dpEnumLookup({ dp: 0x06, name: "action", type: tuya.dataTypes.bool, readOnly: true, expose: expose, lookup: { shake: 0 }, }), ]; }, name: { to: (v, meta) => { const utf8bytes = new node_util_1.TextEncoder().encode(v); return Array.from(utf8bytes, (utf8bytes) => utf8bytes.toString(16).padStart(4, "0")).join(""); }, from: (v, meta) => { const bytes = []; for (let i = 0; i < v.length; i += 4) { bytes.push(Number.parseInt(v.slice(i, i + 4), 16)); } const hexToBytes = Uint8Array.from(bytes); return new node_util_1.TextDecoder("utf-8").decode(hexToBytes); }, }, nameTrunc: { to: (v, meta, len = 8) => { const truncated = v.slice(0, len); const utf8bytes = new node_util_1.TextEncoder().encode(truncated); return Array.from(utf8bytes, (utf8bytes) => utf8bytes.toString(16).padStart(4, "0")).join(""); }, from: (v, meta) => { const bytes = []; for (let i = 0; i < v.length; i += 4) { bytes.push(Number.parseInt(v.slice(i, i + 4), 16)); } const hexToBytes = Uint8Array.from(bytes); return new node_util_1.TextDecoder("utf-8").decode(hexToBytes); }, }, }; // TS0601_smart_scene_knob constants and helpers // // 4 physical buttons with knob rotation, 3 modes: Scene (DP 1-4), Light (ZCL), Curtain (DP broadcast) // Mode switch: hold button 2 or 4 for 5 seconds (only cycles through bound modes) // Group ID pattern: base + (button-1) * 20, detected from first button press const modeScene = 0x01; const modeLight = 0x03; const modeCurtain = 0x04; const groupIdOffset = 20; const statusUnassigned = "unassigned"; const statusWaiting = "waiting_button_1"; const statusReady = "ready"; const getButtonFromGroupId = (groupId, baseGroupId) => { if (!baseGroupId || !groupId) return null; const offset = groupId - baseGroupId; if (offset >= 0 && offset % groupIdOffset === 0) { const button = Math.floor(offset / groupIdOffset) + 1; if (button >= 1 && button <= 4) return button; } return null; }; // DP 102 payload: [0x01, 0x01, mode, ...name(12 bytes), ...suffix(4 bytes)] // Suffix: slot number at position (slot-1), rest 0xff const bindSlotTS0601SmartSceneKnob = async (entity, slot, mode) => { const modeNames = { [modeScene]: "Scene", [modeLight]: "Light", [modeCurtain]: "Curtain" }; const slotName = `${modeNames[mode] || "Scene"} ${slot}`; const nameBuffer = Buffer.alloc(12, 0); nameBuffer.write(slotName, "utf8"); const suffix = Buffer.from([0xff, 0xff, 0xff, 0xff]); suffix[slot - 1] = slot; const payload = Buffer.concat([Buffer.from([0x01, 0x01, mode]), nameBuffer, suffix]); await tuya.sendDataPointRaw(entity, 102, payload, "dataRequest", 0x10 + (slot - 1)); }; const trv603ScheduleConverter = (dayNumber) => { return { from: (value) => { const buf = Buffer.isBuffer(value) ? value : Array.isArray(value) ? Buffer.from(value) : value && typeof value === "object" && "data" in value ? Buffer.from(value.data) : null; if (!buf || buf.length < 17) return; const schedule = []; for (let i = 1; i <= 13; i += 4) { const hh = buf[i]; const mm = buf[i + 1]; const tempRaw = (buf[i + 2] << 8) | buf[i + 3]; const temp = (tempRaw / 10).toFixed(1); if (hh > 23 || mm > 59) return; schedule.push(`${hh.toString().padStart(2, "0")}:${mm.toString().padStart(2, "0")}/${temp}`); } return schedule.join(" "); }, to: (value) => { const parts = value.split(" "); const buf = Buffer.alloc(17); buf[0] = dayNumber; parts.forEach((part, index) => { if (index < 4) { const [time, temp] = part.split("/"); const [hh, mm] = time.split(":"); const offset = 1 + index * 4; const tempVal = Math.round(Number.parseFloat(temp) * 10); buf[offset] = Number.parseInt(hh, 10); buf[offset + 1] = Number.parseInt(mm, 10); buf[offset + 2] = (tempVal >> 8) & 0xff; buf[offset + 3] = tempVal & 0xff; } }); return Array.from(buf); }, }; }; // AR331 Pro (DP 106): holiday start/end as 9-byte LE: [prefix, ts_start_LE4, ts_end_LE4] const ar331ProHolidayTimeConverter = { from: (v) => { if (!v || v.length < 9) return ""; const readLE32 = (arr, offset) => (arr[offset] | (arr[offset + 1] << 8) | (arr[offset + 2] << 16) | (arr[offset + 3] * 16777216)) >>> 0; const startTS = readLE32(v, 1); const endTS = readLE32(v, 5); const fmt = (ts) => { const d = new Date(ts * 1000); const pad = (n) => String(n).padStart(2, "0"); return `${d.getUTCFullYear()}/${pad(d.getUTCMonth() + 1)}/${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; }; return `${fmt(startTS)} | ${fmt(endTS)}`; }, to: (v) => { const [startStr, endStr] = v.split("|").map((s) => s.trim()); const parseTS = (s) => { const [datePart, timePart] = s.split(" "); const [y, m, d] = datePart.split("/").map(Number); const [h, min] = timePart.split(":").map(Number); const ts = Math.floor(Date.UTC(y, m - 1, d, h, min) / 1000); return [ts & 0xff, (ts >> 8) & 0xff, (ts >> 16) & 0xff, (ts >> 24) & 0xff]; }; return [0, ...parseTS(startStr), ...parseTS(endStr)]; }, }; const tzLocal = { acmelec_ae720k_state_double_on: { key: ["state"], convertSet: async (entity, key, value, meta) => { const on = String(value).toLowerCase() === "on"; if (!on) { await tuya.sendDataPointBool(entity, 1, false); return { state: { state: "OFF" } }; } // AE-720K requires ON twice (similar to pressing ON twice on device) await tuya.sendDataPointBool(entity, 1, true); await new Promise((r) => setTimeout(r, 220)); await tuya.sendDataPointBool(entity, 1, true); return { state: { state: "ON" } }; }, }, ts0049_countdown: { key: ["water_countdown"], convertSet: async (entity, key, value, meta) => { utils.assertNumber(value); const data = Buffer.alloc(5); const scaledValue = value * 60; // data.writeUInt32BE(scaledValue, 1); // data[0] = 0x0b; // await entity.command("manuSpecificTuyaE001", "setCountdown", { data }); }, }, ts110eCountdown: { key: ["countdown"], convertSet: async (entity, key, value, meta) => { utils.assertNumber(value); const data = Buffer.alloc(4); data.writeUInt32LE(value); await entity.command("genOnOff", "tuyaCountdown", { data }); }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS030F_border: { key: ["border"], convertSet: async (entity, key, value, meta) => { const lookup = { up: 0, down: 1, up_delete: 2, down_delete: 3 }; await entity.write(0xe001, { 57345: { value: utils.getFromLookup(value, lookup), type: 0x30 }, }); }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0726_switch_mode: { key: ["switch_mode"], convertSet: async (entity, key, value, meta) => { await entity.write(0xe001, { 53280: { value: utils.getFromLookup(value, { switch: 0, scene: 1 }), type: 0x30, }, }); return { state: { switch_mode: value } }; }, }, led_control: { key: ["brightness", "color", "color_temp", "transition"], options: [exposes.options.color_sync()], convertSet: async (entity, _key, _value, meta) => { const newState = {}; // The color mode encodes whether the light is using its white LEDs or its color LEDs let colorMode = meta.state.color_mode ?? constants_1.colorModeLookup[constants_1.ColorMode.ColorTemp]; // Color mode switching is done by setting color temperature (switch to white LEDs) or setting color (switch // to color LEDs) if ("color_temp" in meta.message) colorMode = constants_1.colorModeLookup[constants_1.ColorMode.ColorTemp]; if ("color" in meta.message) colorMode = constants_1.colorModeLookup[constants_1.ColorMode.HS]; if (colorMode !== meta.state.color_mode) { newState.color_mode = colorMode; // To switch between white mode and color mode, we have to send a special command: const rgbMode = colorMode === constants_1.colorModeLookup[constants_1.ColorMode.HS] ? 1 : 0; await entity.command("lightingColorCtrl", "tuyaRgbMode", { enable: rgbMode, }); } // A transition time of 0 would be treated as about 1 second, probably some kind of fallback/default // transition time, so for "no transition" we use 1 (tenth of a second). const transtime = typeof meta.message.transition === "number" ? meta.message.transition * 10 : 0.1; if (colorMode === constants_1.colorModeLookup[constants_1.ColorMode.ColorTemp]) { if ("brightness" in meta.message) { await entity.command("genLevelCtrl", "moveToLevel", { level: Number(meta.message.brightness), transtime, optionsMask: 0, optionsOverride: 0 }, utils.getOptions(meta.mapped, entity)); newState.brightness = meta.message.brightness; } if ("color_temp" in meta.message) { await entity.command("lightingColorCtrl", "moveToColorTemp", { colortemp: meta.message.color_temp, transtime: transtime, optionsMask: 0, optionsOverride: 0, }, utils.getOptions(meta.mapped, entity)); newState.color_temp = meta.message.color_temp; } } else if (colorMode === constants_1.colorModeLookup[constants_1.ColorMode.HS]) { if ("brightness" in meta.message || "color" in meta.message) { // We ignore the brightness of the color and instead use the overall brightness setting of the lamp // for the brightness because I think that's the expected behavior and also because the color // conversion below always returns 100 as brightness ("value") even for very dark colors, except // when the color is completely black/zero. // Load current state or defaults const newSettings = { brightness: meta.state.brightness ?? 254, // full brightness // @ts-expect-error ignore hue: meta.state.color?.hue ?? 0, // red // @ts-expect-error ignore saturation: meta.state.color?.saturation ?? 100, // full saturation }; // Apply changes if ("brightness" in meta.message) { newSettings.brightness = meta.message.brightness; newState.brightness = meta.message.brightness; } if ("color" in meta.message) { // The Z2M UI sends `{ hex:'#xxxxxx' }`. // Home Assistant sends `{ h: xxx, s: xxx }`. // We convert the former into the latter. const c = libColor.Color.fromConverterArg(meta.message.color); if (c.isRGB()) { // https://github.com/Koenkk/zigbee2mqtt/issues/13421#issuecomment-1426044963 c.hsv = c.rgb.gammaCorrected().toXY().toHSV(); } const color = c.hsv; newSettings.hue = color.hue; newSettings.saturation = color.saturation; newState.color = { hue: color.hue, saturation: color.saturation, }; } // Convert to device specific format and send const brightness = utils.toNumber(newSettings.brightness, "brightness"); const zclData = { brightness: utils.mapNumberRange(brightness, 0, 254, 0, 1000), hue: newSettings.hue, saturation: utils.mapNumberRange(newSettings.saturation, 0, 100, 0, 1000), }; // This command doesn't support a transition time await entity.command("lightingColorCtrl", "tuyaMoveToHueAndSaturationBrightness2", zclData, utils.getOptions(meta.mapped, entity)); } } // If we're in white mode, calculate a matching display color for the set color temperature. This also kind // of works in the other direction. Object.assign(newState, libColor.syncColorState(newState, meta.state, entity, meta.options)); return { state: newState }; }, convertGet: async (entity, key, meta) => { await entity.read("genLevelCtrl", ["currentLevel"]); await entity.read("lightingColorCtrl", [ "currentHue", "currentSaturation", "tuyaRgbMode", "colorTemperature", ]); }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0504B_color: { key: ["color"], convertSet: async (entity, key, value, meta) => { const color = libColor.Color.fromConverterArg(value); const enableWhite = (color.isRGB() && color.rgb.red === 1 && color.rgb.green === 1 && color.rgb.blue === 1) || // Zigbee2MQTT frontend white value (color.isXY() && (color.xy.x === 0.3125 || color.xy.y === 0.32894736842105265)) || // Home Assistant white color picker value (color.isXY() && (color.xy.x === 0.323 || color.xy.y === 0.329)); if (enableWhite) { await entity.command("lightingColorCtrl", "tuyaRgbMode", { enable: 0 }); const newState = { color_mode: "xy" }; if (color.isXY()) { newState.color = color.xy; } else { newState.color = color.rgb.gammaCorrected().toXY().rounded(4); } return { state: libColor.syncColorState(newState, meta.state, entity, meta.options), }; } return await tz.light_color.convertSet(entity, key, value, meta); }, convertGet: tz.light_color.convertGet, }, TS0224: { key: ["light", "duration", "volume"], convertSet: async (entity, key, value, meta) => { if (key === "light") { utils.assertString(value, "light"); await entity.command("genOnOff", value.toLowerCase() === "on" ? "on" : "off", {}, utils.getOptions(meta.mapped, entity)); } else if (key === "duration") { await entity.write("ssIasWd", { maxDuration: value }, utils.getOptions(meta.mapped, entity)); } else if (key === "volume") { const lookup = { mute: 0, low: 10, medium: 30, high: 50 }; utils.assertString(value, "volume"); const lookupValue = lookup[value]; value = value.toLowerCase(); utils.validateValue(value, Object.keys(lookup)); await entity.write("ssIasWd", { 2: { value: lookupValue, type: 0x0a } }, utils.getOptions(meta.mapped, entity)); } return { state: { [key]: value } }; }, }, temperature_unit: { key: ["temperature_unit"], convertSet: async (entity, key, value, meta) => { switch (key) { case "temperature_unit": { utils.assertString(value, "temperature_unit"); await entity.write("manuSpecificTuya2", { "57355": { value: { celsius: 0, fahrenheit: 1 }[value], type: 48 }, }); break; } default: // Unknown key logger_1.logger.warning(`Unhandled key ${key}`, NS); } }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS011F_threshold: { key: [ "temperature_threshold", "temperature_breaker", "power_threshold", "power_breaker", "over_current_threshold", "over_current_breaker", "over_voltage_threshold", "over_voltage_breaker", "under_voltage_threshold", "under_voltage_breaker", ], convertSet: async (entity, key, value, meta) => { const onOffLookup = { on: 1, off: 0 }; switch (key) { case "temperature_threshold": { const state = meta.state.temperature_breaker; const buf = Buffer.from([5, utils.getFromLookup(state, onOffLookup), 0, utils.toNumber(value, "temperature_threshold")]); await entity.command("manuSpecificTuya3", "setOptions2", { data: buf, }); break; } case "temperature_breaker": { const threshold = meta.state.temperature_threshold; const number = utils.toNumber(threshold, "temperature_threshold"); const buf = Buffer.from([5, utils.getFromLookup(value, onOffLookup), 0, number]); await entity.command("manuSpecificTuya3", "setOptions2", { data: buf, }); break; } case "power_threshold": { const state = meta.state.power_breaker; const buf = Buffer.from([7, utils.getFromLookup(state, onOffLookup), 0, utils.toNumber(value, "power_breaker")]); await entity.command("manuSpecificTuya3", "setOptions2", { data: buf, }); break; } case "power_breaker": { const threshold = meta.state.power_threshold; const number = utils.toNumber(threshold, "power_breaker"); const buf = Buffer.from([7, utils.getFromLookup(value, onOffLookup), 0, number]); await entity.command("manuSpecificTuya3", "setOptions2", { data: buf, }); break; } case "over_current_threshold": { const state = meta.state.over_current_breaker; const buf = Buffer.from([1, utils.getFromLookup(state, onOffLookup), 0, utils.toNumber(value, "over_current_threshold")]); await entity.command("manuSpecificTuya3", "setOptions3", { data: buf, }); break; } case "over_current_breaker": { const threshold = meta.state.over_current_threshold; const number = utils.toNumber(threshold, "over_current_threshold"); const buf = Buffer.from([1, utils.getFromLookup(value, onOffLookup), 0, number]); await entity.command("manuSpecificTuya3", "setOptions3", { data: buf, }); break; } case "over_voltage_threshold": { const state = meta.state.over_voltage_breaker; const buf = Buffer.alloc(4); buf.writeUInt8(3, 0); buf.writeUInt8(utils.getFromLookup(state, onOffLookup), 1); buf.writeUInt16BE(utils.toNumber(value, "over_voltage_threshold"), 2); await entity.command("manuSpecificTuya3", "setOptions3", { data: buf, }); break; } case "over_voltage_breaker": { const threshold = meta.state.over_voltage_threshold; const number = utils.toNumber(threshold, "over_voltage_threshold"); const buf = Buffer.from([3, utils.getFromLookup(value, onOffLookup), 0, number]); await entity.command("manuSpecificTuya3", "setOptions3", { data: buf, }); break; } case "under_voltage_threshold": { const state = meta.state.under_voltage_breaker; const buf = Buffer.from([4, utils.getFromLookup(state, onOffLookup), 0, utils.toNumber(value, "under_voltage_threshold")]); await entity.command("manuSpecificTuya3", "setOptions3", { data: buf, }); break; } case "under_voltage_breaker": { const threshold = meta.state.under_voltage_threshold; const number = utils.toNumber(threshold, "under_voltage_breaker"); const buf = Buffer.from([4, utils.getFromLookup(value, onOffLookup), 0, number]); await entity.command("manuSpecificTuya3", "setOptions3", { data: buf, }); break; } default: // Unknown key logger_1.logger.warning(`Unhandled key ${key}`, NS); } }, }, invert_cover_percent_fix: { key: ["state", "position"], convertSet: async (entity, key, value, meta) => { const shouldInvert = key === "position" && meta.options.cover_position_percent_fix; const newValue = shouldInvert ? 100 - Number(value) : value; return await legacy.toZigbee.tuya_cover_control.convertSet(entity, key, newValue, meta); }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0505B_1_transitionFixesOnOffBrightness: { ...tz.light_onoff_brightness, convertSet: async (entity, key, value, meta) => { // This light has two issues: // 1. If passing transition = 0, it will behave as if transition = 1s. // 2. If turning off with a transition, and turning on during the transition, it will turn off // at the end of the first transition timer, despite order to turn on // Workaround for issue 1: patch transition in input message const transition = utils.getTransition(entity, "brightness", meta); let transitionSeconds = transition.time / 10; let newMeta = meta; if (transitionSeconds === 0) { const { message } = meta; const wantedState = message.state != null ? (typeof message.state === "string" ? message.state.toLowerCase() : null) : undefined; newMeta = { ...meta }; // Clone meta to avoid modifying the original if (wantedState === "off") { // Erase transition because that way we get actual instant turn off newMeta.message = { state: "OFF" }; } else { // Best we can do is set the transition to 0.1 seconds // That is the same thing as is done for TS0505B_2 transitionSeconds = 0.1; newMeta.message = { ...message, transition: transitionSeconds }; // Will get re-parsed by original light_onoff_brightness } } const ret = await tz.light_onoff_brightness.convertSet(entity, key, value, newMeta); // Workaround for issue 2: // Get the current state of the light after transition time + 0.1s // This won't fix the light's state, but at least it will make us aware that it's off, // allowing user apps to turn it on again if needed. // This could probably be improved by actually turning it on again if necessary. if (transitionSeconds !== 0) { setTimeout(() => { tz.on_off.convertGet(entity, "state", meta).catch((error) => { logger_1.logger.warning(`Error getting state of TS0505B_1 after transition: ${error.message}`, NS); }); }, transitionSeconds * 1000 + 100); } return ret; }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0601_knob_dimmer_switch_group_id: { key: ["group_id"], convertSet: async (entity, key, value, meta) => { // The device uses custom group command known from miboxer switches to bind to a group. await entity.command("genGroups", "miboxerSetZones", { zones: [{ zoneNum: 1, groupId: Number(value) }], }); }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0601_smart_light_group_id: { key: ["group_id"], convertSet: async (entity, key, value, meta) => { // The device uses custom group command known from miboxer switches to bind to a group. const zone_map = Object.fromEntries(Object.keys(meta.state) .filter((key) => key.startsWith("group_id")) .map((k) => [k.replace("group_id_l", ""), meta.state[k]])); zone_map[meta.endpoint_name.replace("l", "")] = value; await entity.command("genGroups", "miboxerSetZones", { zones: Object.entries(zone_map).map(([k, v]) => { return { zoneNum: Number(k), groupId: Number(v) }; }), }); return { state: { group_id: value } }; }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0004_backlight_mode: { key: ["backlight_mode"], convertSet: async (entity, key, value, meta) => { const lookup = { red_when_on: 0, pink_when_on: 1, red_on_blue_off: 2, pink_on_blue_off: 3 }; const modeValue = utils.getFromLookup(value, lookup); await entity.write("genOnOff", { tuyaBacklightMode: modeValue }); return { state: { backlight_mode: value } }; }, }, // TS0601_smart_scene_knob // // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0601_smart_scene_knob_bind_all: { key: ["bind_all_scene", "bind_all_light", "bind_all_curtain"], convertSet: async (entity, key, value, meta) => { const modes = { bind_all_scene: modeScene, bind_all_light: modeLight, bind_all_curtain: modeCurtain, }; const mode = modes[key]; for (let slot = 1; slot <= 4; slot++) { await bindSlotTS0601SmartSceneKnob(entity, slot, mode); if (slot < 4) await new Promise((resolve) => setTimeout(resolve, 500)); } // Scene mode doesn't use Group ID if (key === "bind_all_scene") { return {}; } const currentStatus = meta.state?.assignment_status; const baseGroupId = meta.state?.base_group_id; if (currentStatus !== statusReady || !baseGroupId) { return { state: { assignment_status: statusWaiting } }; } return {}; }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0601_smart_scene_knob_assign_button_1: { key: ["assign_button_1"], convertSet: (entity, key, value, meta) => { return { state: { assignment_status: statusWaiting } }; }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0601_smart_scene_knob_set_base_group_id: { key: ["set_base_group_id"], convertSet: (entity, key, value, meta) => { const baseGroupId = Number.parseInt(value, 10); if (Number.isNaN(baseGroupId) || baseGroupId < 1 || baseGroupId > 65000) return {}; return { state: { base_group_id: baseGroupId, assignment_status: statusReady, }, }; }, }, ts0201_temperature_humidity_alarm: { key: ["alarm_humidity_max", "alarm_humidity_min", "alarm_temperature_max", "alarm_temperature_min"], convertSet: async (entity, key, value, meta) => { switch (key) { case "alarm_temperature_max": case "alarm_temperature_min": case "alarm_humidity_max": case "alarm_humidity_min": { // await entity.write('manuSpecificTuya2', {[key]: value}); // instead write as custom attribute to override incorrect herdsman dataType from uint16 to int16 // https://github.com/Koenkk/zigbee-herdsman/blob/v0.13.191/src/zcl/definition/cluster.ts#L4235 const keyToAttributeLookup = { alarm_temperature_max: 0xd00a, alarm_temperature_min: 0xd00b, alarm_humidity_max: 0xd00d, alarm_humidity_min: 0xd00e, }; const payload = { [keyToAttributeLookup[key]]: { value: value, type: zigbee_herdsman_1.Zcl.DataType.INT16 } }; await entity.write("manuSpecificTuya2", payload); break; } default: // Unknown key logger_1.logger.warning(`Unhandled key ${key}`, NS); } }, }, }; const fzLocal = { TLSR82xxAction: { cluster: "genOnOff", type: ["attributeReport", "readResponse"], convert: (model, msg, publish, options, meta) => { if (Object.hasOwn(msg.data, "onOff")) { const btn = msg.endpoint.ID; const state = msg.data["onOff"] === 1 ? "on" : "off"; return { action: `${state}_${btn}` }; } }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0726_action: { cluster: "genOnOff", type: ["commandTuyaAction"], convert: (model, msg, publish, options, meta) => { return { action: `scene_${msg.endpoint.ID}` }; }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0222_humidity: { ...fz.humidity, convert: (model, msg, publish, options, meta) => { const result = fz.humidity.convert(model, msg, publish, options, meta); if (result) result.humidity *= 10; return result; }, }, scene_recall: { cluster: "genScenes", type: "commandRecall", convert: (model, msg, publish, op