UNPKG

zigbee-herdsman-converters

Version:

Collection of device converters to be used with zigbee-herdsman

1,070 lines • 834 kB
"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 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 } = 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 signedPower = options[signedPowerKey] != null ? options[signedPowerKey] : false; if (signedPower) { result[`power_${channel}`] = sign * power; result[`energy_flow_${channel}`] = "sign"; } else { result[`power_${channel}`] = power; result[`energy_flow_${channel}`] = sign > 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); }, 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; }, }; 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) { priv.flushZero(result, channel, options); return result; } 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) { priv.flushZero(result, channel, options); return result; } 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 {}; }, }; }, 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); }, }, }; const tzLocal = { 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]; 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) { const zclData = { level: Number(meta.message.brightness), transtime }; await entity.command("genLevelCtrl", "moveToLevel", zclData, utils.getOptions(meta.mapped, entity)); newState.brightness = meta.message.brightness; } if ("color_temp" in meta.message) { const zclData = { colortemp: meta.message.color_temp, transtime: transtime, }; await entity.command("lightingColorCtrl", "moveToColorTemp", zclData, 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("lightingColorCtrl", ["currentHue", "currentSaturation", "currentLevel", "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: false, }); 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]; // biome-ignore lint/style/noParameterAssign: ignored using `--suppress` 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("manuSpecificTuya_2", { "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("manuSpecificTuya_3", "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("manuSpecificTuya_3", "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("manuSpecificTuya_3", "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("manuSpecificTuya_3", "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("manuSpecificTuya_3", "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("manuSpecificTuya_3", "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("manuSpecificTuya_3", "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("manuSpecificTuya_3", "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("manuSpecificTuya_3", "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("manuSpecificTuya_3", "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); }, }, }; const fzLocal = { // 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, options, meta) => { if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model)) return; const payload = { action: (0, utils_1.postfixWithEndpointName)(`scene_${msg.data.sceneid}`, msg, model, meta), }; (0, utils_1.addActionGroup)(payload, msg, model); return payload; }, }, scenes_recall_scene_65029: { cluster: "65029", type: ["raw", "attributeReport"], convert: (model, msg, publish, options, meta) => { const id = meta.device.modelID === "005f0c3b" ? msg.data[0] : msg.data[msg.data.length - 1]; return { action: `scene_${id}` }; }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0201_battery: { cluster: "genPowerCfg", type: ["attributeReport", "readResponse"], convert: (model, msg, publish, options, meta) => { // https://github.com/Koenkk/zigbee2mqtt/issues/11470 // https://github.com/Koenkk/zigbee-herdsman-converters/pull/8246 if (msg.data.batteryPercentageRemaining === 200 && msg.data.batteryVoltage < 30 && !["_TZ3000_lqmvrwa2"].includes(meta.device.manufacturerName)) return; return fz.battery.convert(model, msg, publish, options, meta); }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS0201_humidity: { ...fz.humidity, convert: (model, msg, publish, options, meta) => { if (["_TZ3210_ncw88jfq", "_TZ3000_ywagc4rj"].includes(meta.device.manufacturerName)) { msg.data.measuredValue *= 10; } return fz.humidity.convert(model, msg, publish, options, meta); }, }, humidity10: { cluster: "msRelativeHumidity", type: ["attributeReport", "readResponse"], convert: (model, msg, publish, options, meta) => { const humidity = Number.parseFloat(msg.data.measuredValue) / 10.0; if (humidity >= 0 && humidity <= 100) { return { humidity }; } }, }, temperature_unit: { cluster: "manuSpecificTuya_2", type: ["attributeReport", "readResponse"], convert: (model, msg, publish, options, meta) => { const result = {}; if (msg.data["57355"] !== undefined) { result.temperature_unit = utils.getFromLookup(msg.data["57355"], { "0": "celsius", "1": "fahrenheit", }); } return result; }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS011F_electrical_measurement: { ...fz.electrical_measurement, convert: async (model, msg, publish, options, meta) => { const result = fz.electrical_measurement.convert(model, msg, publish, options, meta) ?? {}; const lookup = { power: "activePower", current: "rmsCurrent", voltage: "rmsVoltage", }; // Wait 5 seconds before reporting a 0 value as this could be an invalid measurement. // https://github.com/Koenkk/zigbee2mqtt/issues/16709#issuecomment-1509599046 if (result) { for (const key of ["power", "current", "voltage"]) { if (key in result) { const value = result[key]; clearTimeout(globalStore.getValue(msg.endpoint, key)); if (value === 0) { const configuredReporting = msg.endpoint.configuredReportings.find((c) => c.cluster.name === "haElectricalMeasurement" && c.attribute.name === lookup[key]); const time = (configuredReporting ? configuredReporting.minimumReportInterval : 5) * 2 + 1; globalStore.putValue(msg.endpoint, key, setTimeout(() => { const payload = { [key]: value }; // Device takes a lot of time to report power 0 in some cases. When current == 0 we can assume power == 0 // https://github.com/Koenkk/zigbee2mqtt/discussions/19680#discussioncomment-7868445 if (key === "current") { payload.power = 0; } publish(payload); }, time * 1000)); delete result[key]; } } } } // Device takes a lot of time to report power 0 in some cases. When the state is OFF we can assume power == 0 // https://github.com/Koenkk/zigbee2mqtt/discussions/19680#discussioncomment-7868445 if (meta.state.state === "OFF") { result.power = 0; } return result; }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` TS011F_threshold: { cluster: "manuSpecificTuya_3", type: "raw", convert: (model, msg, publish, options, meta) => { const splitToAttributes = (value) => { const result = {}; const len = value.length; let i = 0; while (i < len) { const key = value.readUInt8(i); result[key] = [value.readUInt8(i + 1), value.readUInt16BE(i + 2)]; i += 4; } return result; }; const lookup = { 0: "OFF", 1: "ON" }; const command = msg.data[2]; const data = msg.data.slice(3); if (command === 0xe6) { const value = splitToAttributes(data); const result = {}; if (0x05 in value) { result.temperature_threshold = value[0x05][1]; result.temperature_breaker = lookup[value[0x05][0]]; } if (0x07 in value) { result.power_threshold = value[0x07][1]; result.power_breaker = lookup[value[0x07][0]]; } return result; } if (command === 0xe7) { const value = splitToAttributes(data); return { over_current_threshold: value[0x01][1], over_current_breaker: lookup[value[0x01][0]], over_voltage_threshold: value[0x03][1], over_voltage_breaker: lookup[value[0x03][0]], under_voltage_threshold: value[0x04][1], under_voltage_breaker: lookup[value[0x04][0]], }; } }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` PJ1203A_sync_time_increase_seq: { cluster: "manuSpecificTuya", type: ["commandMcuSyncTime"], convert: (model, msg, publish, options, meta) => { const priv = storeLocal.getPrivatePJ1203A(meta.device); priv.last_seq += priv.seq_inc; }, }, // biome-ignore lint/style/useNamingConvention: ignored using `--suppress` PJ1203A_strict_fz_datapoints: { ...tuya.fz.datapoints, convert: (model, msg, publish, options, meta) => { // Uncomment the next line to test the behavior when random messages are lost // if ( Math.random() < 0.05 ) return; const priv = storeLocal.getPrivatePJ1203A(meta.device); // Detect missing or re-ordered messages but allow duplicate messages (should we?). const expectedSeq = (priv.last_seq + priv.seq_inc) & 0xffff; if (msg.data.seq !== expectedSeq && msg.data.seq !== priv.last_seq) { logger_1.logger.debug(`[PJ1203A] Missing or re-ordered message detected: Got seq=${msg.data.seq}, expected ${priv.next_seq}`, NS); priv.clear(); } priv.last_seq = msg.data.seq; // And finally, process the datapoint using tuya.fz.datapoints return tuya.fz.datapoints.convert(model, msg, publish, options, meta); }, }, }; exports.definitions = [ { zigbeeModel: ["TS0204"], model: "TS0204", vendor: "Tuya", description: "Gas sensor", whiteLabel: [{ vendor: "Tesla Smart", model: "TSL-SEN-GAS" }], fromZigbee: [fz.ias_gas_alarm_1, fz.ignore_basic_report], toZigbee: [], exposes: [e.gas(), e.tamper()], }, { zigbeeModel: ["TS0205"], model: "TS0205", vendor: "Tuya", description: "Smoke sensor", whiteLabel: [ { vendor: "Tesla Smart", model: "TSL-SEN-SMOKE" }, { vendor: "Dongguan Daying Electornics Technology", model: "YG400A" }, tuya.whitelabel("Tuya", "TS0205_smoke_2", "Optical smoke sensor (model YG500A on the PCB)", ["_TZ3210_up3pngle"]), tuya.whitelabel("Nedis", "ZBDS10WT", "Smoke sensor", ["_TYZB01_wqcac7lo"]), ], // Configure battery % fails // https://github.com/Koenkk/zigbee2mqtt/issues/22421 extend: [ m.battery({ percentageReporting: false }), m.iasZoneAlarm({ zoneType: "smoke", zoneAttributes: ["alarm_1", "tamper"], }), ], configure: async (device, coordinatorEndpoint) => { if (device?.manufacturerName === "_TZ3210_up3pngle") { // Required for this version // https://github.com/Koenkk/zigbee-herdsman-converters/pull/8004 const endpoint = device.getEndpoint(1); await reporting.bind(endpoint, coordinatorEndpoint, ["genPowerCfg"]); await reporting.batteryPercentageRemaining(endpoint); } }, }, { zigbeeModel: ["TS0111"], model: "TS0111", vendor: "Tuya", description: "Socket", extend: [tuya.modernExtend.tuyaOnOff()], }, { zigbeeModel: ["TS0218"], model: "TS0218", vendor: "Tuya", description: "Button", fromZigbee: [fz.command_emergency, fz.battery], exposes: [e.battery(), e.action(["click"])], toZigbee: [], }, { zigbeeModel: ["TS0203"], model: "TS0203", vendor: "Tuya", description: "Door/window sensor", fromZigbee: [fz.ias_contact_alarm_1, fz.battery, fz.ignore_basic_report, fz.ias_contact_alarm_1_report], toZigbee: [], whiteLabel: [ { vendor: "CR Smart Home", model: "TS0203" }, { vendor: "Tuya", model: "iH-F001" }, { vendor: "Tesla Smart", model: "TSL-SEN-DOOR" }, { vendor: "Cleverio", model: "SS100" }, tuya.whitelabel("Niceboy", "ORBIS Windows & Door Sensor", "Door sensor", ["_TZ3000_qrldbmfn"]), tuya.whitelabel("Tuya", "ZD06", "Door window sensor", ["_TZ3000_rcuyhwe3"]), tuya.whitelabel("Tuya", "ZD08", "Door sensor", ["_TZ3000_7d8yme6f"]), tuya.whitelabel("Tuya", "MC500A", "Door sensor", ["_TZ3000_2mbfxlzr"]), tuya.whitelabel("Tuya", "19DZT", "Door sensor", ["_TZ3000_n2egfsli"]), tuya.whitelabel("Tuya", "DS04", "Door sensor", ["_TZ3000_yfekcy3n"]), tuya.whitelabel("Moes", "ZSS-JM-GWM-C-MS", "Smart door and window sensor", ["_TZ3000_decxrtwa"]), tuya.whitelabel("Moes", "ZSS-S01-GWM-C-MS", "Door/window alarm sensor", ["_TZ3000_8yhypbo7"]), tuya.whitelabel("Moes", "ZSS-X-GWM-C", "Door/window magnetic sensor", ["_TZ3000_gntwytxo"]), tuya.whitelabel("Luminea", "ZX-5232", "Smart door and window sensor", ["_TZ3000_4ugnzsli"]), tuya.whitelabel("QA", "QASD1", "Door sensor", ["_TZ3000_udyjylt7"]), tuya.whitelabel("Nous", "E3", "Door sensor", ["_TZ3000_v7chgqso"]), tuya.whitelabel("Woox", "R7047", "Smart Door & Window Sensor", ["_TZ3000_timx9ivq"]), ], exposes: (device, options) => { const exps = [e.contact(), e.battery(), e.battery_voltage()]; const noTamperModels = [ // manufacturerName for models without a tamper sensor "_TZ3000_rcuyhwe3", // Tuya ZD06 "_TZ3000_2mbfxlzr", // Tuya MC500A "_TZ3000_n2egfsli", // Tuya 19DZT "_TZ3000_yfekcy3n", // Tuya DS04 "_TZ3000_bpkijo14", "_TZ3000_gntwytxo", // Moes ZSS-X-GWM-C "_TZ3000_4ugnzsli", // Luminea ZX-5232 "_TZ3000_timx9ivq", //Woox R7047 ]; if (!device || !noTamperModels.includes(device.manufacturerName)) { exps.push(e.tamper()); } const noBatteryLowModels = ["_TZ3000_26fmupbb", "_TZ3000_oxslv1c9", "_TZ3000_osu834un", "_TZ3000_timx9ivq"]; if (!device || !noBatteryLowModels.includes(device.manufacturerName)) { exps.push(e.battery_low()); } return exps; }, meta: { battery: { // These sensors do send a Battery Percentage Remaining (0x0021) // value, but is usually incorrect. For example, a coin battery tested // with a load tester may show 80%, but report 2.5V / 1%. This voltage // calculation matches what ZHA does by default. // https://github.com/Koenkk/zigbee2mqtt/discussions/17337 // https://github.com/zigpy/zha-device-handlers/blob/c6ed94a52a469e72b32ece2a92d528060c7fd034/zhaquirks/__init__.py#L195-L228 voltageToPercentage: "3V_1500_2800", }, }, configure: async (device, coordinatorEndpoint) => { try { const endpoint = device.getEndpoint(1); await reporting.bind(endpoint, coordinatorEndpoint, ["genPowerCfg"]); await reporting.batteryPercentageRemaining(endpoint); await reporting.batteryVoltage(endpoint); } catch { /* Fails for some*/ } const endpoint = device.getEndpoint(1); if (endpoint.binds.some((b) => b.cluster.name === "genPollCtrl")) { await endpoint.unbind("genPollCtrl", coordinatorEndpoint); } }, }, { fingerprint: tuya.fingerprint("TS0203", ["_TZ3210_jowhpxop"]), model: "TS0203_1", vendor: "Tuya", description: "Door sensor with scene switch", fromZigbee: [tuya.fz.datapoints, fz.ias_contact_alarm_1, fz.battery, fz.ignore_basic_report, fz.ias_contact_alarm_1_report], toZigbee: [tuya.tz.datapoints], onEvent: tuya.onEventSetTime, configure: tuya.configureMagicPacket, exposes: [e.action(["single", "double", "hold"]), e.contact(), e.battery_low(), e.tamper(), e.battery(), e.battery_voltage()], meta: { tuyaDatapoints: [[101, "action", tuya.valueConverterBasic.lookup({ single: 0, double: 1, hold: 2 })]], }, whiteLabel: [tuya.whitelabel("Linkoze", "LKDSZ001", "Door sensor with scene switch", ["_TZ3210_jowhpxop"])], }, { fingerprint: tuya.fingerprint("TS0021", ["_TZ3210_3ulg9kpo"]), model: "LKWSZ211", vendor: "Linkoze", description: "Scene remote with 2 keys", fromZigbee: [tuya.fz.datapoints, fz.ignore_basic_report], toZigbee: [tuya.tz.datapoints], onEvent: tuya.onEventSetTime, configure: tuya.configureMagicPacket, exposes: [ e.battery(), e.action(["button_1_single", "button_1_double", "button_1_hold", "button_2_single", "button_2_double", "button_2_hold"]), ], meta: { tuyaDatapoints: [ [ 1, "action", tuya.valueConverterBasic.lookup({ button_1_single: tuya.enum(0), button_1_double: tuya.enum(1), button_1_hold: tuya.enum(2), }), ], [ 2, "action", tuya.valueConverterBasic.lookup({ button_2_single: tuya.enum(0), button_2_double: tuya.enum(1), button_2_hold: tuya.enum(2), }), ], [10, "battery", tuya.valueConverter.raw], ], }, }, { fingerprint: tuya.fingerprint("TS0601", [ "_TZE200_bq5c8xfe", "_TZE200_bjawzodf", "_TZE200_qyflbnbj", "_TZE200_44af8vyi", "_TZE200_zl1kmjqx", "_TZE204_qyflbnbj", "_TZE284_qyflbnbj", ]), model: "TS0601_temperature_humidity_sensor_1", vendor: "Tuya", description: "Temperature & humidity sensor", fromZigbee: [legacy.fromZigbee.tuya_temperature_humidity_sensor], toZigbee: [], exposes: (device, options) => { const exps = [e.temperature(), e.humidity(), e.battery()]; if (!device || device.manufacturerName === "_TZE200_qyflbnbj" || device.manufacturerName === "_TZE204_qyflbnbj" || device.manufacturerName === "_TZE284_qyflbnbj") { exps.push(e.battery_low()); exps.push(e.enum("battery_level", ea.STATE, ["low", "middle", "high"]).withDescription("Battery level state")); } return exps; }, }, { fingerprint: tuya.fingerprint("TS0601", ["_TZE200_mfamvsdb"]), model: "F00MB00-04-1", vendor: "FORIA", description: "4 scenes switch",