zigbee-herdsman-converters
Version:
Collection of device converters to be used with zigbee-herdsman
1,070 lines • 834 kB
JavaScript
"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",