zigbee-herdsman-converters
Version:
Collection of device converters to be used with zigbee-herdsman
1,053 lines • 1.24 MB
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 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