zigbee-herdsman-converters
Version:
Collection of device converters to be used with zigbee-herdsman
8,838 lines • 425 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.toZigbee = exports.fromZigbee = exports.modernExtend = exports.lumiModernExtend = exports.manufacturerCode = exports.trv = exports.presence = exports.numericAttributes2Payload = exports.buffer2DataObject = void 0;
exports.w100EnsureDefaults = w100EnsureDefaults;
const node_buffer_1 = require("node:buffer");
const zigbee_herdsman_1 = require("zigbee-herdsman");
const fz = __importStar(require("../converters/fromZigbee"));
const tz = __importStar(require("../converters/toZigbee"));
const exposes = __importStar(require("./exposes"));
const logger_1 = require("./logger");
const modernExtend = __importStar(require("./modernExtend"));
const globalStore = __importStar(require("./store"));
const utils_1 = require("./utils");
const NS = "zhc:lumi";
const e = exposes.presets;
const ea = exposes.access;
const ZNCLBL01LM_RUNNING_STORE_KEY = "ZNCLBL01LM_running";
const ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY = "ZNCLBL01LM_terminal_position";
const ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY = "ZNCLBL01LM_terminal_target_position";
const buffer2DataObject = (model, buffer) => {
const dataObject = {};
if (buffer !== null && node_buffer_1.Buffer.isBuffer(buffer)) {
// Lumi struct parsing
for (let i = 0; i < buffer.length - 1; i++) {
const index = buffer[i];
let value = null;
switch (buffer[i + 1]) {
case 16:
case 32:
// 0x10 ZclBoolean
// 0x20 Zcl8BitUint
value = buffer.readUInt8(i + 2);
i += 2;
break;
case 33:
// 0x21 Zcl16BitUint
value = buffer.readUInt16LE(i + 2);
i += 3;
break;
case 34:
// 0x22 Zcl24BitUint
value = buffer.readUIntLE(i + 2, 3);
i += 4;
break;
case 35:
// 0x23 Zcl32BitUint
value = buffer.readUInt32LE(i + 2);
i += 5;
break;
case 36:
// 0x24 Zcl40BitUint
value = buffer.readUIntLE(i + 2, 5);
i += 6;
break;
case 37:
// 0x25 Zcl48BitUint
value = buffer.readUIntLE(i + 2, 6);
i += 7;
break;
case 38:
// 0x26 Zcl56BitUint
value = buffer.readUIntLE(i + 2, 7);
i += 8;
break;
case 39:
// 0x27 Zcl64BitUint
value = buffer.readBigUInt64BE(i + 2);
i += 9;
break;
case 40:
// 0x28 Zcl8BitInt
value = buffer.readInt8(i + 2);
i += 2;
break;
case 41:
// 0x29 Zcl16BitInt
value = buffer.readInt16LE(i + 2);
i += 3;
break;
case 42:
// 0x2A Zcl24BitInt
value = buffer.readIntLE(i + 2, 3);
i += 4;
break;
case 43:
// 0x2B Zcl32BitInt
value = buffer.readInt32LE(i + 2);
i += 5;
break;
case 44:
// 0x2C Zcl40BitInt
value = buffer.readIntLE(i + 2, 5);
i += 6;
break;
case 45:
// 0x2D Zcl48BitInt
value = buffer.readIntLE(i + 2, 6);
i += 7;
break;
case 46:
// 0x2E Zcl56BitInt
value = buffer.readIntLE(i + 2, 7);
i += 8;
break;
case 47:
// 0x2F Zcl64BitInt
value = buffer.readBigInt64BE(i + 2);
i += 9;
break;
case 57:
// 0x39 ZclSingleFloat
value = buffer.readFloatLE(i + 2);
i += 5;
break;
case 58:
// 0x3a ZclDoubleFloat
value = buffer.readDoubleLE(i + 2);
i += 5;
break;
case 66:
// 0x42 unknown, length taken from what seems correct in the logs, maybe is wrong
logger_1.logger.debug(`${model.model}: unknown vtype=${buffer[i + 1]}, pos=${i + 1}, moving length 1`, NS);
i += 2;
break;
case 95:
// 0x5f unknown, length taken from what seems correct in the logs, maybe is wrong
logger_1.logger.debug(`${model.model}: unknown vtype=${buffer[i + 1]}, pos=${i + 1}, moving length 4`, NS);
i += 5;
break;
default:
logger_1.logger.debug(`${model.model}: unknown vtype=${buffer[i + 1]}, pos=${i + 1}`, NS);
}
if (value != null) {
dataObject[index] = value;
}
}
}
logger_1.logger.debug(`${model.model}: Processed buffer into data \
${JSON.stringify(dataObject, (key, value) => (typeof value === "bigint" ? value.toString() : value))}`, NS);
return dataObject;
};
exports.buffer2DataObject = buffer2DataObject;
const numericAttributes2Payload = async (
// biome-ignore lint/suspicious/noExplicitAny: too generic to properly type
msg, meta, model, options, dataObject) => {
let payload = {};
for (const [key, value] of Object.entries(dataObject)) {
switch (key) {
case "0":
payload.detection_period = value;
break;
case "1":
payload.voltage = value;
if (model.meta?.battery?.voltageToPercentage) {
(0, utils_1.assertNumber)(value);
payload.battery = (0, utils_1.batteryVoltageToPercentage)(value, model.meta.battery.voltageToPercentage);
}
break;
case "2":
if (["JT-BZ-01AQ/A"].includes(model.model)) {
(0, utils_1.assertNumber)(value);
payload.power_outage_count = value - 1;
}
break;
case "3":
if (["WXCJKG11LM", "WXCJKG12LM", "WXCJKG13LM", "MCCGQ14LM", "GZCGQ01LM", "JY-GZ-01AQ", "CTP-R01"].includes(model.model)) {
// The temperature value is constant 25 °C and does not change, so we ignore it
// https://github.com/Koenkk/zigbee2mqtt/issues/11126
// https://github.com/Koenkk/zigbee-herdsman-converters/pull/3585
// https://github.com/Koenkk/zigbee2mqtt/issues/13253
}
else {
(0, utils_1.assertNumber)(value);
payload.device_temperature = value; // 0x03
}
break;
case "4":
if ([
"WS-USC01",
"WS-USC02",
"WS-EUK01",
"WS-EUK02",
"QBKG27LM",
"QBKG28LM",
"QBKG29LM",
"QBKG25LM",
"QBKG38LM",
"QBKG39LM",
"ZNQBKG42LM",
"ZNQBKG43LM",
"ZNQBKG44LM",
"ZNQBKG45LM",
].includes(model.model)) {
payload.mode_switch = (0, utils_1.getFromLookup)(value, { 4: "anti_flicker_mode", 1: "quick_mode" });
}
break;
case "5":
(0, utils_1.assertNumber)(value);
payload.power_outage_count = value - 1;
break;
case "6":
if (["MCCGQ11LM", "SJCGQ11LM"].includes(model.model)) {
(0, utils_1.assertNumber)(value);
let count = value;
// Sometimes, especially when the device is connected through another lumi router, the sensor
// send random values after 16 bit (>65536), so we truncate and read this as 16BitUInt.
count = Number.parseInt(count.toString(16).slice(-4), 16);
payload.trigger_count = count - 1;
}
break;
case "8":
if (["ZNLDP13LM"].includes(model.model)) {
// We don't know what the value means for these devices.
}
break;
case "9":
if (["ZNLDP13LM", "ZNXDD01LM"].includes(model.model)) {
// We don't know what the value means for these devices.
}
break;
case "10":
// Value 29146 is received for SSM-U02 sometimes here:
// https://github.com/Koenkk/zigbee2mqtt/issues/17961#issuecomment-1616170548
if (["SSM-U01", "DLKZMK11LM", "SSM-U02", "DLKZMK12LM"].includes(model.model) && (value === 1 || value === 2)) {
payload.switch_type = (0, utils_1.getFromLookup)(value, { 1: "toggle", 2: "momentary" });
}
break;
case "11":
if (["RTCGQ11LM"].includes(model.model)) {
(0, utils_1.assertNumber)(value);
payload.illuminance = value;
}
break;
case "12":
if (["ZNLDP13LM", "ZNXDD01LM"].includes(model.model)) {
// We don't know what the value means for these devices.
}
break;
case "13":
if (["ZNXDD01LM"].includes(model.model)) {
// We don't know what the value means for these devices.
}
else if (["ZNCLBL01LM", "PS-S04D"].includes(model.model)) {
// Overwrite version advertised by `genBasic` and `genOta` with correct version:
// https://github.com/Koenkk/zigbee2mqtt/issues/15745
(0, utils_1.assertNumber)(value);
meta.device.meta.lumiFileVersion = value;
meta.device.softwareBuildID = exports.trv.decodeFirmwareVersionString(value);
meta.device.save();
}
break;
case "17":
if (["ZNXDD01LM"].includes(model.model)) {
// We don't know what the value means for these devices.
}
break;
case "100":
if ([
"QBKG18LM",
"QBKG20LM",
"QBKG31LM",
"QBKG39LM",
"QBKG41LM",
"QBCZ15LM",
"LLKZMK11LM",
"QBKG12LM",
"QBKG03LM",
"QBKG25LM",
].includes(model.model)) {
// biome-ignore lint/suspicious/noImplicitAnyLet: ignored using `--suppress`
let mapping;
switch (model.model) {
case "QBCZ15LM":
mapping = "relay";
break;
case "LLKZMK11LM":
mapping = "l1";
break;
default:
mapping = "left";
}
payload[`state_${mapping}`] = value === 1 ? "ON" : "OFF";
}
else if (["WXKG14LM", "WXKG16LM", "WXKG17LM"].includes(model.model)) {
payload.click_mode = (0, utils_1.getFromLookup)(value, { 1: "fast", 2: "multi" });
}
else if (["WXCJKG11LM", "WXCJKG12LM", "WXCJKG13LM", "ZNMS12LM", "ZNCLBL01LM", "RTCGQ12LM", "RTCGQ13LM", "RTCGQ14LM"].includes(model.model)) {
// We don't know what the value means for these devices.
// https://github.com/Koenkk/zigbee2mqtt/issues/11126
// https://github.com/Koenkk/zigbee2mqtt/issues/12279
}
else if (["RTCGQ15LM"].includes(model.model)) {
payload.occupancy = value;
}
else if (["PS-S04D"].includes(model.model)) {
payload.presence = value === 1;
}
else if (["WSDCGQ01LM", "WSDCGQ11LM", "WSDCGQ12LM", "VOCKQJK11LM"].includes(model.model)) {
// https://github.com/Koenkk/zigbee2mqtt/issues/798
// Sometimes the sensor publishes non-realistic vales, filter these
// @ts-expect-error ignore
const temperature = Number.parseFloat(value) / 100.0;
if (temperature > -65 && temperature < 65) {
payload.temperature = temperature;
}
}
else if (["RTCGQ11LM"].includes(model.model)) {
// It contains the occupancy, but in z2m we use a custom timer to do it, so we ignore it
// payload.occupancy = value === 1;
}
else if (["MCCGQ11LM", "MCCGQ14LM"].includes(model.model)) {
payload.contact = value === 0;
}
else if (["SJCGQ11LM"].includes(model.model)) {
// Ignore the message. It seems not reliable. See discussion here https://github.com/Koenkk/zigbee2mqtt/issues/12018
// payload.water_leak = value === 1;
}
else if (["SJCGQ13LM"].includes(model.model)) {
payload.water_leak = value === 1;
}
else if (["JTYJ-GD-01LM/BW"].includes(model.model)) {
payload.smoke_density = value;
}
else if (["GZCGQ01LM"].includes(model.model)) {
(0, utils_1.assertNumber)(value);
payload.illuminance = value;
}
else {
payload.state = value === 1 ? "ON" : "OFF";
}
break;
case "101":
if ([
"QBKG18LM",
"QBKG20LM",
"QBKG31LM",
"QBKG39LM",
"QBKG41LM",
"QBCZ15LM",
"QBKG25LM",
"QBKG33LM",
"QBKG34LM",
"LLKZMK11LM",
"QBKG12LM",
"QBKG03LM",
].includes(model.model)) {
// biome-ignore lint/suspicious/noImplicitAnyLet: ignored using `--suppress`
let mapping;
switch (model.model) {
case "QBCZ15LM":
mapping = "usb";
break;
case "QBKG25LM":
case "QBKG33LM":
case "QBKG34LM":
mapping = "center";
break;
case "LLKZMK11LM":
mapping = "l2";
break;
default:
mapping = "right";
}
payload[`state_${mapping}`] = value === 1 ? "ON" : "OFF";
}
else if (["RTCGQ12LM", "RTCGQ14LM", "RTCGQ15LM"].includes(model.model)) {
// Sometimes RTCGQ14LM reports high illuminance values in the dark
// https://github.com/Koenkk/zigbee2mqtt/issues/12596
(0, utils_1.assertNumber)(value);
const illuminance = value > 65000 ? 0 : value;
payload.illuminance = illuminance;
}
else if (["WSDCGQ01LM", "WSDCGQ11LM", "WSDCGQ12LM", "VOCKQJK11LM"].includes(model.model)) {
// https://github.com/Koenkk/zigbee2mqtt/issues/798
// Sometimes the sensor publishes non-realistic vales, filter these
// @ts-expect-error ignore
const humidity = Number.parseFloat(value) / 100.0;
if (humidity >= 0 && humidity <= 100) {
payload.humidity = humidity;
}
}
else if (["ZNJLBL01LM", "ZNCLDJ12LM"].includes(model.model)) {
payload.battery = value;
}
else if (["ZNCLBL01LM"].includes(model.model)) {
(0, utils_1.assertNumber)(value);
const battery = value / 2;
payload.battery = (0, utils_1.precisionRound)(battery, 2);
}
else if (["RTCZCGQ11LM"].includes(model.model)) {
payload.presence = (0, utils_1.getFromLookup)(value, { 0: false, 1: true, 255: null });
}
else if (["ZNXDD01LM"].includes(model.model)) {
payload.brightness = value;
}
break;
case "102":
if (["QBKG25LM", "QBKG33LM", "QBKG34LM"].includes(model.model)) {
payload.state_right = value === 1 ? "ON" : "OFF";
}
else if (["TH-S04D"].includes(model.model)) {
payload.battery = value;
}
else if (["WSDCGQ01LM", "WSDCGQ11LM"].includes(model.model)) {
(0, utils_1.assertNumber)(value);
payload.pressure = value / 100.0;
}
else if (["WSDCGQ12LM"].includes(model.model)) {
// This pressure value is ignored because it is less accurate than reported in the 'scaledValue' attribute
// of the 'msPressureMeasurement' cluster
}
else if (["RTCZCGQ11LM"].includes(model.model)) {
if (meta.device.applicationVersion < 50) {
payload.presence_event = (0, utils_1.getFromLookup)(value, {
0: "enter",
1: "leave",
2: "left_enter",
3: "right_leave",
4: "right_enter",
5: "left_leave",
6: "approach",
7: "away",
255: null,
});
}
else {
payload.motion_sensitivity = (0, utils_1.getFromLookup)(value, { 1: "low", 2: "medium", 3: "high" });
}
}
else if (["ZNXDD01LM"].includes(model.model)) {
payload.color_temp = value;
}
break;
case "103":
if (["RTCZCGQ11LM"].includes(model.model)) {
payload.monitoring_mode = (0, utils_1.getFromLookup)(value, { 0: "undirected", 1: "left_right" });
}
else if (["ZNXDD01LM"].includes(model.model)) {
// const color_temp_min = (value & 0xffff); // 2700
// const color_temp_max = (value >> 16) & 0xffff; // 6500
}
else if (["PS-S04D"].includes(model.model)) {
payload.pir_detection = value === 1;
}
break;
case "105":
if (["RTCGQ13LM"].includes(model.model)) {
payload.motion_sensitivity = (0, utils_1.getFromLookup)(value, { 1: "low", 2: "medium", 3: "high" });
}
else if (["RTCZCGQ11LM"].includes(model.model)) {
payload.approach_distance = (0, utils_1.getFromLookup)(value, { 0: "far", 1: "medium", 2: "near" });
}
else if (["RTCGQ14LM"].includes(model.model)) {
payload.detection_interval = value;
}
break;
case "106":
if (["RTCGQ14LM"].includes(model.model)) {
payload.motion_sensitivity = (0, utils_1.getFromLookup)(value, { 1: "low", 2: "medium", 3: "high" });
}
break;
case "107":
if (["RTCGQ14LM"].includes(model.model)) {
payload.trigger_indicator = value === 1;
}
else if (["ZNCLBL01LM"].includes(model.model)) {
// https://github.com/Koenkk/zigbee-herdsman-converters/pull/11911
const value1057 = typeof dataObject["1057"] === "number" ? dataObject["1057"] : undefined;
const storedRunning = globalStore.getValue(msg.endpoint, ZNCLBL01LM_RUNNING_STORE_KEY, undefined);
const payloadRunning = value1057 !== undefined && value1057 < 2;
const shouldSuppressInStopPayload = value1057 === 2;
const shouldSuppressWhileStopped = storedRunning === false && !payloadRunning;
if (shouldSuppressInStopPayload || shouldSuppressWhileStopped) {
break;
}
(0, utils_1.assertNumber)(value);
const position = options.invert_cover ? 100 - value : value;
payload.position = position;
payload.state = options.invert_cover ? (position > 0 ? "CLOSE" : "OPEN") : position > 0 ? "OPEN" : "CLOSE";
}
break;
case "149":
(0, utils_1.assertNumber)(value);
payload.energy = value; // 0x95
if (["LLKZMK12LM"].includes(model.model)) {
(0, utils_1.assertNumber)(payload.energy);
payload.energy = payload.energy / 1000;
}
// Consumption is deprecated
payload.consumption = payload.energy;
break;
case "150":
if (["KD-R01D", "WS-K05E"].includes(model.model)) {
(0, utils_1.assertNumber)(value);
payload.voltage = value * 0.01;
}
else if (!["JTYJ-GD-01LM/BW"].includes(model.model)) {
(0, utils_1.assertNumber)(value);
payload.voltage = value * 0.1; // 0x96
}
break;
case "151":
if (["LLKZMK11LM"].includes(model.model)) {
(0, utils_1.assertNumber)(value);
payload.current = value;
}
else {
(0, utils_1.assertNumber)(value);
payload.current = value * 0.001;
}
break;
case "152":
if (["DJT11LM"].includes(model.model)) {
// We don't know what implies for this device, it contains values like 30, 50,... that don't seem to change
}
else {
(0, utils_1.assertNumber)(value);
payload.power = value; // 0x98
}
break;
case "154":
if (["ZNLDP13LM", "ZNXDD01LM"].includes(model.model)) {
// We don't know what the value means for these devices.
}
break;
case "159":
if (["JT-BZ-01AQ/A"].includes(model.model)) {
payload.gas_sensitivity = (0, utils_1.getFromLookup)(value, { 1: "15%LEL", 2: "10%LEL" });
}
else if (["MCCGQ13LM"].includes(model.model)) {
payload.detection_distance = (0, utils_1.getFromLookup)(value, { 1: "10mm", 2: "20mm", 3: "30mm" });
}
break;
case "160":
if (["JT-BZ-01AQ/A"].includes(model.model)) {
payload.gas = value === 1;
}
else if (["JY-GZ-01AQ"].includes(model.model)) {
payload.smoke = value === 1;
}
break;
case "161":
if (["JT-BZ-01AQ/A"].includes(model.model)) {
payload.gas_density = value;
}
else if (["JY-GZ-01AQ"].includes(model.model)) {
payload.smoke_density = value;
payload.smoke_density_dbm = (0, utils_1.getFromLookup)(value, {
0: 0,
1: 0.085,
2: 0.088,
3: 0.093,
4: 0.095,
5: 0.1,
6: 0.105,
7: 0.11,
8: 0.115,
9: 0.12,
10: 0.125,
});
}
break;
case "162":
if (["JT-BZ-01AQ/A", "JY-GZ-01AQ"].includes(model.model)) {
payload.test = value === 1;
}
break;
case "163":
if (["JT-BZ-01AQ/A", "JY-GZ-01AQ"].includes(model.model)) {
payload.buzzer_manual_mute = value === 1;
}
break;
case "164":
if (["JT-BZ-01AQ/A"].includes(model.model)) {
payload.state = (0, utils_1.getFromLookup)(value, { 0: "work", 1: "preparation" });
}
else if (["JY-GZ-01AQ"].includes(model.model)) {
payload.heartbeat_indicator = value === 1;
}
break;
case "165":
if (["JY-GZ-01AQ"].includes(model.model)) {
payload.linkage_alarm = value === 1;
}
break;
case "166":
if (["JT-BZ-01AQ/A"].includes(model.model)) {
payload.linkage_alarm = value === 1;
}
break;
case "238":
if (["ZNXDD01LM"].includes(model.model)) {
// We don't know what the value means for these devices.
}
else if (["ZNCLBL01LM"].includes(model.model)) {
// Overwrite version advertised by `genBasic` and `genOta` with correct version:
// https://github.com/Koenkk/zigbee2mqtt/issues/15745
(0, utils_1.assertNumber)(value);
meta.device.meta.lumiFileVersion = value;
meta.device.softwareBuildID = exports.trv.decodeFirmwareVersionString(value);
meta.device.save();
}
break;
case "240":
payload.flip_indicator_light = value === 1 ? "ON" : "OFF";
break;
case "247":
{
const dataObject247 = (0, exports.buffer2DataObject)(model, value);
if (["CTP-R01"].includes(model.model)) {
// execute pending soft switch of operation_mode, if exists
const opModeSwitchTask = globalStore.getValue(meta.device, "opModeSwitchTask");
if (opModeSwitchTask) {
const { callback, newMode } = opModeSwitchTask;
try {
await callback();
payload.operation_mode = newMode;
globalStore.clearValue(meta.device, "opModeSwitchTask");
}
catch {
// do nothing when callback fails
}
}
else {
payload.operation_mode = (0, utils_1.getFromLookup)(dataObject247[155], { 0: "action_mode", 1: "scene_mode" });
}
}
const payload247 = await (0, exports.numericAttributes2Payload)(msg, meta, model, options, dataObject247);
payload = { ...payload, ...payload247 };
}
break;
case "258":
payload.detection_interval = value;
break;
case "268":
if (["RTCGQ13LM", "RTCGQ14LM", "RTCZCGQ11LM", "PS-S04D"].includes(model.model)) {
payload.motion_sensitivity = (0, utils_1.getFromLookup)(value, { 1: "low", 2: "medium", 3: "high" });
}
else if (["JT-BZ-01AQ/A"].includes(model.model)) {
payload.gas_sensitivity = (0, utils_1.getFromLookup)(value, { 1: "15%LEL", 2: "10%LEL" });
}
break;
case "293":
payload.click_mode = (0, utils_1.getFromLookup)(value, { 1: "fast", 2: "multi" });
break;
case "294":
if (["JT-BZ-01AQ/A", "JY-GZ-01AQ"].includes(model.model)) {
payload.buzzer_manual_mute = value === 1;
}
break;
case "295":
if (["JT-BZ-01AQ/A", "JY-GZ-01AQ"].includes(model.model)) {
payload.test = value === 1;
}
break;
case "313":
if (["JT-BZ-01AQ/A"].includes(model.model)) {
payload.state = (0, utils_1.getFromLookup)(value, { 0: "work", 1: "preparation" });
}
break;
case "314":
if (["JT-BZ-01AQ/A"].includes(model.model)) {
payload.gas = value === 1;
}
else if (["JY-GZ-01AQ"].includes(model.model)) {
payload.smoke = value === 1;
}
break;
case "315":
if (["JT-BZ-01AQ/A"].includes(model.model)) {
payload.gas_density = value;
}
else if (["JY-GZ-01AQ"].includes(model.model)) {
payload.smoke_density = value;
payload.smoke_density_dbm = (0, utils_1.getFromLookup)(value, {
0: 0,
1: 0.085,
2: 0.088,
3: 0.093,
4: 0.095,
5: 0.1,
6: 0.105,
7: 0.11,
8: 0.115,
9: 0.12,
10: 0.125,
});
}
break;
case "316":
if (["JY-GZ-01AQ"].includes(model.model)) {
payload.heartbeat_indicator = value === 1;
}
break;
case "317":
if (["JT-BZ-01AQ/A", "JY-GZ-01AQ"].includes(model.model)) {
payload.buzzer_manual_alarm = value === 1;
}
break;
case "320":
if (["MCCGQ13LM"].includes(model.model)) {
payload.tamper = (0, utils_1.getFromLookup)(value, { 0: false, 1: true });
}
break;
case "322":
if (["RTCZCGQ11LM", "PS-S04D"].includes(model.model)) {
payload.presence = (0, utils_1.getFromLookup)(value, { 0: false, 1: true, 255: null });
}
break;
case "323":
if (["RTCZCGQ11LM"].includes(model.model)) {
payload.presence_event = (0, utils_1.getFromLookup)(value, {
0: "enter",
1: "leave",
2: "left_enter",
3: "right_leave",
4: "right_enter",
5: "left_leave",
6: "approach",
7: "away",
});
}
break;
case "324":
if (["RTCZCGQ11LM"].includes(model.model)) {
payload.monitoring_mode = (0, utils_1.getFromLookup)(value, { 0: "undirected", 1: "left_right" });
}
break;
case "326":
if (["RTCZCGQ11LM"].includes(model.model)) {
payload.approach_distance = (0, utils_1.getFromLookup)(value, { 0: "far", 1: "medium", 2: "near" });
}
break;
case "328":
if (["CTP-R01"].includes(model.model)) {
// detected hard switch of operation_mode (attribute 0x148[328])
payload.operation_mode = (0, utils_1.getFromLookup)(msg.data[328], { 0: "action_mode", 1: "scene_mode" });
}
break;
case "329":
if (["CTP-R01"].includes(model.model)) {
// side_up attribute report (attribute 0x149[329])
payload.action = "side_up";
payload.side = msg.data[329] + 1;
}
break;
case "331":
if (["JT-BZ-01AQ/A", "JY-GZ-01AQ"].includes(model.model)) {
payload.linkage_alarm = value === 1;
}
break;
case "332":
if (["JT-BZ-01AQ/A", "JY-GZ-01AQ"].includes(model.model)) {
payload.linkage_alarm_state = value === 1;
}
break;
case "338":
if (["RTCGQ14LM"].includes(model.model)) {
payload.trigger_indicator = value === 1;
}
break;
case "512":
if (["ZNCZ15LM", "QBCZ14LM", "QBCZ15LM", "SP-EUC01"].includes(model.model)) {
payload.button_lock = value === 1 ? "OFF" : "ON";
}
else {
const mode = (0, utils_1.getFromLookup)(value, { 1: "control_relay", 0: "decoupled" });
payload[(0, utils_1.postfixWithEndpointName)("operation_mode", msg, model, meta)] = mode;
}
break;
case "513":
payload.power_outage_memory = value === 1;
break;
case "514":
payload.auto_off = value === 1;
break;
case "515":
payload.led_disabled_night = value === 1;
break;
case "519":
payload.consumer_connected = value === 1;
break;
case "523":
(0, utils_1.assertNumber)(value);
payload.overload_protection = (0, utils_1.precisionRound)(value, 2);
break;
case "550":
payload.button_switch_mode = value === 1 ? "relay_and_usb" : "relay";
break;
case "645":
// aqara z1 lock relay
payload.lock_relay = value === 1;
break;
case "1025":
if (["ZNCLBL01LM"].includes(model.model)) {
payload.hand_open = !value;
}
else {
// next values update only when curtain finished initial setup and knows current position
// @ts-expect-error ignore
payload.options = { ...payload.options, reverse_direction: value[2] === "\u0001", hand_open: value[5] === "\u0000" };
}
break;
case "1028":
payload = {
...payload,
motor_state: (0, utils_1.getFromLookup)(value, options.invert_cover ? { 0: "stopped", 1: "closing", 2: "opening" } : { 0: "stopped", 1: "opening", 2: "closing" }),
running: !!value,
};
break;
case "1032":
if (["ZNJLBL01LM"].includes(model.model)) {
payload.motor_speed = (0, utils_1.getFromLookup)(value, { 0: "low", 1: "medium", 2: "high" });
}
break;
case "1033":
if (["ZNJLBL01LM"].includes(model.model)) {
payload.charging_status = value === 1;
}
break;
case "1034":
if (["ZNJLBL01LM"].includes(model.model)) {
payload.battery = value;
}
break;
case "1035":
if (["ZNCLBL01LM"].includes(model.model)) {
payload.voltage = value;
}
break;
case "1055":
if (["ZNCLBL01LM"].includes(model.model)) {
(0, utils_1.assertNumber)(value);
const targetPosition = options.invert_cover ? 100 - value : value;
rememberZNCLBL01LMTerminalTargetPosition(targetPosition, model, msg.endpoint, false);
payload.target_position = normalizeZNCLBL01LMTerminalPosition(targetPosition, model, msg.endpoint);
}
break;
case "1056":
if (["ZNCLBL01LM"].includes(model.model)) {
// This is the "target_state" attribute, which takes the following values: 0: 'OPEN', 1: 'CLOSE', 2: 'STOP'.
// It is not used because the values 0 and 1 are not always reported.
// https://github.com/Koenkk/zigbee-herdsman-converters/pull/4307
}
break;
case "1057":
if (["ZNCLBL01LM"].includes(model.model)) {
const previousRunning = globalStore.getValue(msg.endpoint, ZNCLBL01LM_RUNNING_STORE_KEY, undefined);
payload.motor_state = (0, utils_1.getFromLookup)(value, options.invert_cover ? { 0: "opening", 1: "closing", 2: "stopped" } : { 0: "closing", 1: "opening", 2: "stopped" });
(0, utils_1.assertNumber)(value);
payload.running = value < 2;
globalStore.putValue(msg.endpoint, ZNCLBL01LM_RUNNING_STORE_KEY, payload.running);
if (payload.running) {
globalStore.clearValue(msg.endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY);
}
// https://github.com/Koenkk/zigbee-herdsman-converters/pull/11911
if (!payload.running && previousRunning !== false) {
// After stop, read the final position.
// Attr 107 can still be stale near the end.
msg.endpoint
.read("closuresWindowCovering", ["currentPositionLiftPercentage"])
.catch((error) => logger_1.logger.error(`Failed to read position '${msg.device.ieeeAddr}' (${error})`, NS));
}
}
break;
case "1061":
if (["ZNCLBL01LM"].includes(model.model)) {
payload.action = (0, utils_1.getFromLookup)(value, options.invert_cover ? { 1: "manual_close", 2: "manual_open" } : { 1: "manual_open", 2: "manual_close" });
}
break;
case "1063":
if (["ZNCLBL01LM"].includes(model.model)) {
(0, utils_1.getFromLookup)(value, { 0: "UNLOCK", 1: "LOCK" });
}
break;
case "1064":
if (["ZNCLBL01LM"].includes(model.model)) {
payload.hooks_state = (0, utils_1.getFromLookup)(value, { 0: "unlocked", 1: "locked", 2: "locking", 3: "unlocking" });
payload.hooks_lock = (0, utils_1.getFromLookup)(value, { 0: "UNLOCK", 1: "LOCK", 2: "UNLOCK", 3: "LOCK" });
}
break;
case "1065":
if (["ZNCLBL01LM"].includes(model.model)) {
(0, utils_1.assertNumber)(value);
payload.illuminance = value * 50;
}
break;
case "1289":
payload.dimmer_mode = (0, utils_1.getFromLookup)(value, { 1: "dual_ct", 2: "rgb", 3: "rgbw" });
break;
case "1299":
if (["ZNXDD01LM"].includes(model.model)) {
// maximum color temp (6500)
}
break;
case "1300":
if (["ZNXDD01LM"].includes(model.model)) {
// minimum color temp (2700)
}
break;
case "65281":
{
// @ts-expect-error ignore
const payload65281 = await (0, exports.numericAttributes2Payload)(msg, meta, model, options, value);
payload = { ...payload, ...payload65281 };
}
break;
case "65282":
// This is a a complete structure with attributes, like element 0 for state, element 1 for voltage...
// At this moment we only extract what we are sure, for example, position 0 seems to be always 1 for a
// occupancy sensor, so we ignore it at this moment
// @ts-expect-error ignore
payload.voltage = value[1].elmVal;
if (model.meta?.battery?.voltageToPercentage) {
(0, utils_1.assertNumber)(payload.voltage);
payload.battery = (0, utils_1.batteryVoltageToPercentage)(payload.voltage, model.meta.battery.voltageToPercentage);
}
// @ts-expect-error ignore
payload.power_outage_count = value[4].elmVal - 1;
break;
case "65522":
// Aqara devices (bulbs, relays, etc.) send telemetry data in this attribute.
// Contains runtime statistics and device metadata but meaning is not fully decoded.
break;
case "mode":
(0, utils_1.assertNumber)(value);
payload.operation_mode = ["command", "event"][value];
break;
case "modelId":
// We ignore it, but we add it here to not shown an unknown key in the log
break;
case "illuminance":
// It contains the illuminance and occupancy, but in z2m we use a custom timer to do it, so we ignore it
break;
case "displayUnit":
// Use lumiDisplayUnit modernExtend, but we add it here to not shown an unknown key in the log
break;
case "airQuality":
// Use lumiAirQuality modernExtend, but we add it here to not shown an unknown key in the log
break;
default:
logger_1.logger.debug(`${model.model}: unknown key ${key} with value ${value}`, NS);
}
}
logger_1.logger.debug(`${model.model}: Processed data into payload ${JSON.stringify(payload)}`, NS);
return payload;
};
exports.numericAttributes2Payload = numericAttributes2Payload;
function normalizeZNCLBL01LMTerminalPosition(position, model, endpoint) {
if (!["ZNCLBL01LM"].includes(model.model)) {
return position;
}
if (position === 0 || position === 100) {
globalStore.putValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY, position);
return position;
}
if (globalStore.getValue(endpoint, ZNCLBL01LM_RUNNING_STORE_KEY, undefined) !== false) {
return position;
}
const terminalPosition = globalStore.getValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY, undefined) ??
globalStore.getValue(endpoint, ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY, undefined);
if (terminalPosition === 100 && position >= 98 && position < 100) {
return 100;
}
if (terminalPosition === 0 && position > 0 && position <= 2) {
return 0;
}
return position;
}
function rememberZNCLBL01LMTerminalTargetPosition(position, model, endpoint, clearNonTerminal) {
if (!["ZNCLBL01LM"].includes(model.model)) {
return;
}
if (position === 0 || position === 100) {
globalStore.clearValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY);
globalStore.putValue(endpoint, ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY, position);
}
else if (clearNonTerminal) {
globalStore.clearValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY);
globalStore.clearValue(endpoint, ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY);
}
}
const numericAttributes2Lookup = (model, dataObject) => {
let result = {};
for (const [key, value] of Object.entries(dataObject)) {
switch (key) {
case "247":
{
const dataObject247 = (0, exports.buffer2DataObject)(model, value);
const result247 = numericAttributes2Lookup(model, dataObject247);
result = { ...result, ...result247 };
}
break;
case "65281":
{
const result65281 = numericAttributes2Lookup(model, value);
result = { ...result, ...result65281 };
}
break;
default:
result[key] = value;
}
}
return result;
};
const lumiPresenceConstants = {
region_event_key: 0x0151,
region_event_types: {
Enter: 1,
Leave: 2,
Occupied: 4,
Unoccupied: 8,
},
region_config_write_attribute: 0x0150,
region_config_write_attribute_type: 0x41,
region_config_cmds: {
/**
* Creates new region (or force replaces existing one)
* with new zones definition.
*/
create: 1,
/**
* Modifies existing region.
* Note: unused, as it seems to break existing regions
* (region stops reporting new detection events).
* Use "create" instead, as it replaces existing region with new one.
*/
modify: 2,
/**
* Deletes existing region.
*/
delete: 3,
},
// biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
region_config_regionId_min: 1,
// biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
region_config_regionId_max: 10,
// biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
region_config_zoneY_min: 1,
// biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
region_config_zoneY_max: 7,
// biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
region_config_zoneX_min: 1,
// biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
region_config_zoneX_max: 4,
region_config_cmd_suffix_upsert: 0xff,
region_config_cmd_suffix_delete: 0x00,
};
const lumiPresenceMappers = {
lumi_presence: {
region_event_type_names: {
[lumiPresenceConstants.region_event_types.Enter]: "enter",
[lumiPresenceConstants.region_event_types.Leave]: "leave",
[lumiPresenceConstants.region_event_types.Occupied]: "occupied",
[lumiPresenceConstants.region_event_types.Unoccupied]: "unoccupied",
},
},
};
const parseConfiguredPresenceRegionArray = (value) => {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((region) => {
if (!(0, utils_1.isObject)(region) || typeof region.region_id !== "number" || !Array.isArray(region.zones)) {
return [];
}
if (!region.zones.every((zone) => (0, utils_1.isObject)(zone) && typeof zone.x === "number" && typeof zone.y === "number")) {
return [];
}
return [{ regionId: region.region_id, zones: region.zones }];
});
};
const parseConfiguredPresenceRegionSummary = (value) => {
if (!value || value === "None") {
return [];
}
return value.split(" | ").flatMap((region) => {
const regionMatch = region.match(/^(\d+): (.*?)(?: \(\d+ zones\))?$/);
if (!regionMatch) {
return [];
}
const regionId = Number(regionMatch[1]);
const zones = [];
for (const row of regionMatch[2].split("; ")) {
const rowMatch = row.match(/^y(\d+)=(.+)$/);
if (!rowMatch) {
continue;
}
const y = Number(rowMatch[1]);
for (const range of rowMatch[2].split(",")) {
const rangeMatch = range.match(/^x(\d+)(?:-(\d+))?$/);
if (!rangeMatch) {
continue;
}
const startX = Number(rangeMatch[1]);
const endX = rangeMatch[2] === undefined ? startX : Number(rangeMatch[2]);
for (let x = startX; x <= endX; x++) {
zones.push({ x, y });
}
}
}
return [{ regionId, zones }];
});
};
const parseConfiguredPresenceRegions = (value) => {
if (Array.isArray(value)) {
return parseConfiguredPresenceRegionArray(value);
}
if (!(0, utils_1.isString)(value)) {
return [];
}
try {
return parseConfiguredPresenceRegionArray(JSON.parse(value));
}
catch {
return parseConfiguredPresenceRegionSummary(value);
}
};
const summarizeConfiguredPresenceRegions = (regions) => {
if (!regions.length) {
return "None";
}
return regions
.sort((left, right) => left.regionId - right.regionId)
.map((region) => {
const zonesByY = new Map();
for (const zone of region.zones) {
zonesByY.set(zone.y, [...(zonesByY.get(zone.y) ?? []), zone.x]);
}
const zoneRows = [...zonesByY.entries()]
.sort(([leftY], [rightY]) => leftY - rightY)
.map(([y, xs]) => {
const ranges = [];
const sortedXs = [...new Set(xs)].sort((left, right) => left - right);
const firstX = sortedXs[0];
if (firstX === undefined) {
return `y${y}=none`;
}
let rangeStart = firstX;
let previous = firstX;
for (const x of sortedXs.slice(1)) {
if (x === previous + 1) {
previous = x;
continue;
}
ranges.push(rangeStart === previous ? `x${rangeStart}` : `x${rangeStart}-${previous}`);
rangeStart = x;
previous = x;
}
ranges.push(rangeStart === previous ? `x${rangeStart}` : `x${rangeStart}-${previous}`);
return `y${y}=${ranges.join(",")}`;
});
return `${region.regionId}: ${zoneRows.join("; ")} (${region.zones.length} zones)`;
})
.join(" | ");
};
const buildConfiguredPresenceRegionsState = (regions) => {
return {
configured_regions: summarizeConfiguredPresenceRegions(regions),
};
};
const upsertConfiguredPresenceRegion = (state, region) => {
const regions = parseConfiguredPresenceRegions(state.configured_regions).filter((existingRegion) => existingRegion.regionId !== region.regionId);
regions.push({
regionId: region.regionId,
zones: [...region.zones].sort((left, right) => left.y - right.y || left.x - right.x),
});
return buildConfiguredPresenceRegionsState(regions);
};
const deleteConfiguredPresenceRegion = (state, regionId) => {
return buildConfiguredPresenceRegionsState(parseConfiguredPresenceRegions(state.configured_regions).filter((existingRegion) => existingRegion.regionId !== regionId));
};
exports.presence = {
constants: lumiPresenceConstants,
mappers: lumiPresenceMappers,
encodeXCellsDefinition: (xCells) => {
if (!xCells?.length) {
return 0;
}
return [...xCells.values()].reduce((accumulator, marker) => accumulator + exports.presence.encodeXCellIdx(marker), 0);
},
encodeXCellIdx: (cellXIdx) => {
return 2 ** (cellXIdx - 1);
},
parseAqaraFp1RegionDeleteInput: (input) => {
if (!input || typeof input !== "object") {
return exports.presence.failure({ reason: "NOT_OBJECT" });
}
if (!("region_id" in input) || !exports.presence.isAqaraFp1RegionId(input.region_id)) {
return exports.presence.failure({ reason: "INVALID_REGION_ID" });
}
return {
isSuccess: true,
payload: {
command: {
region_id: input.region_id,
},
},
};
},
parseAqaraFp1RegionUpsertInput: (input) => {
if (!input || typeof input !== "object") {
return exports.presence.failure({ reason: "NOT_OBJECT" });
}
if (!("region_id" in input) || !exports.presence.isAqaraFp1RegionId(input.region_id)) {
return exports.presence.failure({ reason: "INVALID_REGION_ID" });
}
if (!("zones" in input) || !Array.isArray(input.zones) || !input.zones.length) {
return exports.presence.failure({ reason: "ZONES_LIST_EMPTY" });
}
if (!input.zones.every(exports.presence.isAqaraFp1RegionZoneDefinition)) {
return exports.presence.failure({ reason: "INVALID_ZONES" });
}
return {
isSuccess: true,
payload: {
command: {
region_id: input.region_id,
zones: input.zones,
},
},
};
},
// biome-ignore lint/suspicious/noExplicitAny: ignored using `--suppress`
isAqaraFp1RegionId: (value) => {
return (typeof value === "number" &&
value >= exports.presence.constants.region_config_regionId_min &&
value <= exports.presence.constants.region_config_regionId_max);
},
// biome-ignore lint/suspicious/noExplicitAny: ignored using `--suppress`
isAqaraFp1RegionZoneDefinition: (value) => {
return (value &&
typeof value === "object" &&
"x" in value &&
"y" in value &&
typeof value.x === "number" &&
typeof value.y === "number" &&
value.x >= exports.presence.constants.region_config_zoneX_min &&
value.x <= exports.presence.constants.region_config_zoneX_max &&
value.y >= exports.presence.constants.region_config_zoneY_min &&
value.y <= exports.presence.constants.region_config_zoneY_max);
},
failure: (error) => {
return {
isSuccess: false,
error,
};
},
};
const writeAqaraFp1RegionUpsert = async (entity, command) => {
logger_1.logger.debug(`Trying to create region ${command.regionId}`, NS);
const sortedZonesAccumulator = {};
const sortedZonesWithSets = command.zones.reduce((accumulator, zone) => {
if (!accumulator[zone.y]) {
accumulator[zone.y] = new Set();
}
accumulator[zone.y].add(zone.x);
return accumulator;
}, sortedZonesAccumulator);
const sortedZones = Object.entries(sortedZonesWithSets).reduce((acc, [key, value]) => {
const numKey = Number.parseInt(key, 10); // Convert string key back to number
acc[numKey] = Array.from(value);
return acc;
}, {});
const deviceConfig = new Uint8Array(7);
// Command parameters
deviceConfig[0] = exports.presence.constants.region_config_cmds.create;
deviceConfig[1] = command.regionId;
deviceConfig[6] = exports.presence.constants.region_config_cmd_suffix_upsert;
// Zones definition
deviceConfig[2] |= exports.presence.encodeXCellsDefinition(sortedZones["1"]);
deviceConfig[2] |= exports.presence.encodeXCellsDefinition(sortedZones["2"]) << 4;
deviceConfig[3] |= exports.presence.encodeXCellsDefinition(sortedZones["3"]);
deviceConfig[3] |= exports.presence.encodeXCellsDefinition(sortedZones["4"]) << 4;
deviceConfig[4] |= exports.presence.encodeXCellsDefinition(sortedZones["5"]);
deviceConfig[4] |= exports.presence.encodeXCellsDefinition(sortedZones["6"]) << 4;
deviceConfig[5] |= exports.presence.encodeXCellsDefinition(sortedZones["7"]);
logger_1.logger.info(`Create region ${command.regionId} ${(0, utils_1.printNumbersAsHexSequence)([...deviceConfig], 2)}`, NS);
const payload = {
[exports.presence.constants.region_config_write_attribute]: {
value: deviceConfig,
type: exports.presence.constants.region_config_write_attribute_type,
},
};
await entity.write("manuSpecificLumi", payload, { manufacturerCode: exports.manufacturerCode });
};
const writeAqaraFp1RegionDelete = async (entity, command) => {
logger_1.logger.debug(`trying to delete region ${command.regionId}`, NS);
const deviceConfig = new Uint8Array(7);
// Command parameters
deviceConfig[0] = exports.presence.constants.region_config_cmds.delete;
deviceConfig[1] = command.regionId;
deviceConfig[6] = exports.presence.constants.region_config_cmd_suffix_delete;
// Zones definition
deviceConfig[2] = 0;
deviceConfig[3] = 0;
deviceConfig[4] = 0;
deviceConfig[5] = 0;
logger_1.logger.info(`Delete region ${command.regionId} (${(0, utils_1.printNumbersAsHexSequence)([...deviceConfig], 2)})`, NS);
const payload = {
[exports.presence.constants.region_config_write_attribute]: {
value: deviceConfig,
type: exports.presence.constants.region_config_write_attribute_type,
},
};
await entity.write("manuSpecificLumi", payload, { manufacturerCode: exports.manufacturerCode });
};
function readTemperature(buffer, offset) {
return buffer.readUint16BE(offset) / 100;
}
function writeTemperature(buffer, offset, temperature) {
buffer.writeUInt16BE(temperature * 100, offset);
}
const dayNames = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
function readDaySelection(buffer, offset) {
const selectedDays = [];
dayNames.forEach((day, index) => {
if ((buffer[offset] >> (index + 1)) % 2 !== 0) {
selectedDays.push(day);
}
});
return selectedDays;
}
function validateDaySelection(selectedDays) {
selectedDays
.filter((selectedDay) => !dayNames.includes(selectedDay))
.forEach((invalidValue) => {
throw new Error(`The value "${invalidValue}" is not a valid day (available values: ${dayNames.join(", ")})`);
});
}
function writeDaySelection(buffer, offset, selectedDays) {
validateDaySelection(selectedDays);
const bitMap = dayNames.reduce((repeat, dayName, index) => {
const isDaySelected = selectedDays.includes(dayName);
// @ts-expect-error ignore
return repeat | (isDaySelected << (index + 1));
}, 0);
buffer.writeUInt8(bitMap, offset);
}
const timeNextDayFlag = 1 << 15;
function readTime(buffer, offset) {
const minutesWithDayFlag = buffer.readUint16BE(offset);
return minutesWithDayFlag & ~timeNextDayFlag;
}
function validateTime(time) {
const isPositiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
if (!isPositiveInteger(time)) {
throw new Error("Time must be a positive integer number");
}
if (time >= 24 * 60) {
throw new Error("Time must be between 00:00 and 23:59");
}
}
function writeTime(buffer, offset, time, isNextDay) {
validateTime(time);
let minutesWithDayFlag = time;
if (isNextDay) {
minutesWithDayFlag = minutesWithDayFlag | timeNextDayFlag;
}
buffer.writeUInt16BE(minutesWithDayFlag, offset);
}
/**
* Formats a number of minutes into a user-readable 24-hour time notation in the form hh:mm.
*/
function formatTime(timeMinutes, padHour = false) {
const hours = Math.floor(timeMinutes / 60);
const minutes = timeMinutes % 60;
if (padHour)
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
return `${hours}:${String(minutes).padStart(2, "0")}`;
}
/**
* Parses a 24-hour time notation string in the form hh:mm into a number of minutes.
*/
function parseTime(timeString) {
const trimmedTimeString = timeString.trim();
if (!trimmedTimeString.match(/^\d{1,2}:\d{1,2}$/)) {
throw new Error(`Invalid time format: ${trimmedTimeString}. Expected HH:MM format (eg. "21:30")`);
}
const parts = trimmedTimeString.split(":");
if (parts.length !== 2) {
throw new Error(`Cannot parse time string ${trimmedTimeString}`);
}
const hours = Number.parseInt(parts[0], 10);
const minutes = Number.parseInt(parts[1], 10);
if (hours < 0 || hours > 23) {
throw new Error(`Invalid time format: ${trimmedTimeString}. Hours out of bounds - should be between 0 and 23.`);
}
if (minutes < 0 || minutes > 59) {
throw new Error(`Invalid time format: ${trimmedTimeString}. Minutes out of bounds - should be between 0 and 59.`);
}
return hours * 60 + minutes;
}
// Time encoding & decoding (used eg in Aqara FP300) (0xMMHHmmhh)
function encodeTimeFormat(startTime, endTime) {
const start = parseTime(startTime);
const end = parseTime(endTime);
return Math.floor(start / 60) | ((start % 60) << 8) | (Math.floor(end / 60) << 16) | ((end % 60) << 24);
}
function decodeTimeFormat(value) {
if (value < 0 || value > 0xffffffff)
return null;
const startHour = value & 0xff;
const startMin = (value >>> 8) & 0xff;
const endHour = (value >>> 16) & 0xff;
const endMin = (value >>> 24) & 0xff;
if (startHour > 23 || startMin > 59 || endHour > 23 || endMin > 59)
return null;
return {
startTime: formatTime(startHour * 60 + startMin, true),
endTime: formatTime(endHour * 60 + endMin, true),
};
}
// Encodes and decodes detection range composite value (used in FP300) from/to int
function encodeDetectionRangeComposite(detectionRangeValue, rangeCount) {
const composite_values = {};
for (let i = 0; i < rangeCount; ++i) {
composite_values[`detection_range_${i}`] = ((detectionRangeValue >>> i) & 1) === 1;
}
return composite_values;
}
function decodeDetectionRangeComposite(compositeValues, rangeCount) {
let intValue = 0;
for (let i = 0; i < rangeCount; ++i) {
if (compositeValues[`detection_range_${i}`])
intValue |= 1 << i;
}
return intValue;
}
const stringifiedScheduleFragmentSeparator = "|";
const stringifiedScheduleValueSeparator = ",";
exports.trv = {
decodeFirmwareVersionString(value) {
// Add prefix to follow Aqara's versioning schema: https://www.aqara.com/en/version/radiator-thermostat-e1
const firmwareVersionPrefix = "0.0.0_";
// Reinterpret from LE integer to byte sequence(e.g., `[25,8,0,0]` corresponds to 0.0.0_0825)
const buffer = node_buffer_1.Buffer.alloc(4);
buffer.writeUInt32LE(value);
const firmwareVersionNumber = (0, utils_1.toNumber)(buffer.reverse().subarray(1).join(""), "firmwareVersionNumber").toString().padStart(4, "0");
return firmwareVersionPrefix + firmwareVersionNumber;
},
decodePreset(value) {
// Setup mode is the initial device state after powering it ("F11" on display) and not a real preset that can be deliberately
// set by users, therefore it is exposed as a separate flag.
return {
setup: value === 3,
preset: { 2: "away", 1: "auto", 0: "manual" }[value],
};
},
decodeHeartbeat(meta, model, messageBuffer) {
const data = (0, exports.buffer2DataObject)(model, messageBuffer);
const payload = {};
Object.entries(data).forEach(([key, value]) => {
switch (Number.parseInt(key, 10)) {
case 3:
payload.device_temperature = value;
break;
case 5:
(0, utils_1.assertNumber)(value);
payload.power_outage_count = value - 1;
break;
case 10:
// unidentified number, e.g. 32274, 3847
break;
case 13:
(0, utils_1.assertNumber)(value);
payload.firmware_version = exports.trv.decodeFirmwareVersionString(value);
break;
case 17:
// unidentified flag/enum, e.g. 1
break;
case 101:
(0, utils_1.assertNumber)(value);
Object.assign(payload, exports.trv.decodePreset(value));
break;
case 102:
(0, utils_1.assertNumber)(value);
payload.local_temperature = value / 100;
break;
case 103:
// This takes the following values:
// - `occupied_heating_setpoint` if `system_mode` is `heat` and `preset` is `manual`
// - `away_preset_temperature` if `system_mode` is `heat` and `preset` is `away`
// - `5` if `system_mode` is `off`
// It thus behaves similar to `occupied_heating_setpoint` except in `off` mode. Due to this difference,
// this value is written to another property to avoid an inconsistency of the `occupied_heating_setpoint`.
// TODO How to handle this value? Find better name?
(0, utils_1.assertNumber)(value);
payload.internal_heating_setpoint = value / 100;
break;
case 104:
payload.valve_alarm = value === 1;
break;
case 105:
payload.battery = value;
break;
case 106:
// unidentified flag/enum, e.g. 0
break;
}
});
return payload;
},
/**
* Decode a Zigbee schedule configuration message into a schedule configuration object.
*/
decodeSchedule(buffer) {
return {
days: readDaySelection(buffer, 1),
events: [
{ time: readTime(buffer, 2), temperature: readTemperature(buffer, 6) },
{ time: readTime(buffer, 8), temperature: readTemperature(buffer, 12) },
{ time: readTime(buffer, 14), temperature: readTemperature(buffer, 18) },
{ time: readTime(buffer, 20), temperature: readTemperature(buffer, 24) },
],
};
},
validateSchedule(schedule) {
const eventCount = 4;
validateDaySelection(schedule.days);
if (schedule.events.length !== eventCount) {
throw new Error(`The schedule object must contain an array of ${eventCount} time/temperature events`);
}
schedule.events.forEach((event) => {
validateTime(event.time);
if (event.temperature < 5 || event.temperature > 30) {
throw new Error("The temperature must be between 5 and 30 °C");
}
});
// Calculate time durations between events
const durations = schedule.events
.map((entry, index, entries) => {
if (index === 0) {
return 0;
}
const time = entry.time;
const fullDay = 24 * 60;
const previousTime = entries[index - 1].time;
const isNextDay = time < previousTime;
if (isNextDay) {
return fullDay - previousTime + time;
}
return time - previousTime;
})
// Remove first entry which is not a duration
.slice(1);
const minDuration = 60;
const hasInvalidDurations = durations.some((duration) => duration < minDuration);
if (hasInvalidDurations) {
throw new Error("The individual times must be at least 1 hour apart");
}
const maxTotalDuration = 24 * 60;
const totalDuration = durations.reduce((total, duration) => total + duration, 0);
if (totalDuration > maxTotalDuration) {
// this implicitly also makes sure that there is at most one "next day" switch
throw new Error("The start and end times must be at most 24 hours apart");
}
},
/**
* Encodes a schedule object into Zigbee message format.
*/
encodeSchedule(schedule) {
const buffer = node_buffer_1.Buffer.alloc(26);
buffer.writeUInt8(0x04);
writeDaySelection(buffer, 1, schedule.days);
schedule.events.forEach((event, index, events) => {
const offset = 2 + index * 6;
const isNextDay = index > 0 && event.time < events[index - 1].time;
writeTime(buffer, offset, event.time, isNextDay);
writeTemperature(buffer, offset + 4, event.temperature);
});
return buffer;
},
stringifySchedule(schedule) {
const stringifiedScheduleFragments = [schedule.days.join(stringifiedScheduleValueSeparator)];
for (const event of schedule.events) {
const formattedTemperature = Number.isInteger(event.temperature)
? event.temperature.toFixed(1) // add ".0" for usability to signal that floats can be used
: String(event.temperature);
const entryFragments = [formatTime(event.time), formattedTemperature];
stringifiedScheduleFragments.push(entryFragments.join(stringifiedScheduleValueSeparator));
}
return stringifiedScheduleFragments.join(stringifiedScheduleFragmentSeparator);
},
// Parses a schedule configuration string into a configuration object.
parseSchedule(stringifiedSchedule) {
const schedule = { days: [], events: [] };
if (!stringifiedSchedule) {
return schedule;
}
const stringifiedScheduleFragments = stringifiedSchedule.split(stringifiedScheduleFragmentSeparator);
stringifiedScheduleFragments.forEach((fragment, index) => {
if (index === 0) {
// @ts-expect-error ignore
schedule.days.push(...fragment.split(stringifiedScheduleValueSeparator));
}
else {
const entryFragments = fragment.split(stringifiedScheduleValueSeparator);
const entry = { time: parseTime(entryFragments[0]), temperature: Number.parseFloat(entryFragments[1]) };
schedule.events.push(entry);
}
});
return schedule;
},
};
/**
* Converts RGB (0-255) to CIE xy color space
* Used for Aqara RGB devices that require xy format
*/
function lumiRgbToXY(r, g, b) {
let red = r / 255.0;
let green = g / 255.0;
let blue = b / 255.0;
red = red > 0.04045 ? ((red + 0.055) / 1.055) ** 2.4 : red / 12.92;
green = green > 0.04045 ? ((green + 0.055) / 1.055) ** 2.4 : green / 12.92;
blue = blue > 0.04045 ? ((blue + 0.055) / 1.055) ** 2.4 : blue / 12.92;
const X = red * 0.4124564 + green * 0.3575761 + blue * 0.1804375;
const Y = red * 0.2126729 + green * 0.7151522 + blue * 0.072175;
const Z = red * 0.0193339 + green * 0.119192 + blue * 0.9503041;
const sum = X + Y + Z;
if (sum === 0) {
return { x: 0, y: 0 };
}
return {
x: X / sum,
y: Y / sum,
};
}
/**
* Encodes RGB color to Aqara's 4-byte xy format
* Returns [xHi, xLo, yHi, yLo]
*/
function lumiEncodeRgbColor(color) {
if (typeof color !== "object" || color.r === undefined || color.g === undefined || color.b === undefined) {
throw new Error(`Invalid color format. Expected {r: 0-255, g: 0-255, b: 0-255}, got: ${JSON.stringify(color)}`);
}
const r = Number(color.r);
const g = Number(color.g);
const b = Number(color.b);
if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) {
throw new Error(`RGB values must be between 0-255. Got r:${r}, g:${g}, b:${b}`);
}
const xy = lumiRgbToXY(r, g, b);
const xScaled = Math.round(xy.x * 65535);
const yScaled = Math.round(xy.y * 65535);
return [(xScaled >>> 8) & 0xff, xScaled & 0xff, (yScaled >>> 8) & 0xff, yScaled & 0xff];
}
/**
* Generates segment bitmask for T1M and T1 Strip devices
*/
function lumiGenerateSegmentMask(segments, deviceType, maxSegments) {
const maskSize = deviceType === "t1m" ? 4 : 8;
const mask = new Array(maskSize).fill(0);
for (const seg of segments) {
if (seg < 1 || seg > maxSegments) {
throw new Error(`Invalid segment: ${seg}. Must be 1-${maxSegments}`);
}
const bitPos = seg - 1;
const byteIndex = Math.floor(bitPos / 8);
const bitIndex = 7 - (bitPos % 8);
mask[byteIndex] |= 1 << bitIndex;
}
return mask;
}
/**
* Builds packet for T1M and T1 Strip segment color control
*/
function lumiBuildSegmentPacket(segments, color, deviceType, maxSegments, brightness = 254) {
const segmentMask = lumiGenerateSegmentMask(segments, deviceType, maxSegments);
const colorBytes = lumiEncodeRgbColor(color);
if (deviceType === "t1m") {
return [...segmentMask, 0x00, 0x00, 0x00, 0x00, ...colorBytes];
}
const brightnessByte = Math.max(0, Math.min(254, Math.round(brightness)));
return [0x01, 0x01, 0x01, 0x0f, brightnessByte, ...segmentMask, ...colorBytes, 0x00, 0x14];
}
/**
* Parse T1 Strip segment patterns
*/
function lumiSegmentParsePattern(value, maxSegments) {
if (!Number.isInteger(maxSegments) || maxSegments < 1) {
throw new Error(`Invalid maxSegments: ${maxSegments}. Must be a positive integer.`);
}
const trimmed = (value || "").trim().toLowerCase();
if (trimmed === "" || trimmed === "all") {
return Array.from({ length: maxSegments }, (_, i) => i + 1);
}
const segments = new Set();
const parts = trimmed
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0);
if (parts.length === 0) {
return Array.from({ length: maxSegments }, (_, i) => i + 1);
}
for (const part of parts) {
if (lumiSegmentIsNamedPattern(part)) {
const patternSegments = lumiSegmentExpandNamedPattern(part, maxSegments);
for (const seg of patternSegments) {
segments.add(seg);
}
continue;
}
if (part.includes("-")) {
const rangeSegments = lumiSegmentExpandRange(part, maxSegments);
for (const seg of rangeSegments) {
segments.add(seg);
}
continue;
}
const num = lumiSegmentParseNumber(part, maxSegments);
segments.add(num);
}
if (segments.size === 0) {
throw new Error(`No valid segments found in pattern: "${value}". Valid range is 1-${maxSegments}.`);
}
return Array.from(segments).sort((a, b) => a - b);
}
function lumiSegmentIsNamedPattern(part) {
const patterns = ["odd", "even", "first-half", "last-half", "first-third", "middle-third", "last-third"];
return patterns.includes(part);
}
function lumiSegmentExpandNamedPattern(pattern, maxSegments) {
switch (pattern) {
case "odd":
return Array.from({ length: Math.ceil(maxSegments / 2) }, (_, i) => i * 2 + 1);
case "even":
return Array.from({ length: Math.floor(maxSegments / 2) }, (_, i) => (i + 1) * 2).filter((n) => n <= maxSegments);
case "first-half": {
const count = Math.ceil(maxSegments / 2);
return Array.from({ length: count }, (_, i) => i + 1);
}
case "last-half": {
const count = Math.ceil(maxSegments / 2);
const start = maxSegments - count + 1;
return Array.from({ length: count }, (_, i) => start + i);
}
case "first-third": {
const count = Math.ceil(maxSegments / 3);
return Array.from({ length: count }, (_, i) => i + 1);
}
case "last-third": {
const count = Math.ceil(maxSegments / 3);
const start = maxSegments - count + 1;
return Array.from({ length: count }, (_, i) => start + i);
}
case "middle-third": {
const count = Math.ceil(maxSegments / 3);
const start = Math.floor(maxSegments / 3) + 1;
return Array.from({ length: count }, (_, i) => start + i).filter((n) => n <= maxSegments);
}
default:
throw new Error(`Unknown pattern: ${pattern}`);
}
}
function lumiSegmentExpandRange(rangeStr, maxSegments) {
const dashCount = (rangeStr.match(/-/g) || []).length;
if (dashCount !== 1) {
throw new Error(`Invalid range format: "${rangeStr}". Expected format: "start-end" (e.g., "1-10").`);
}
const parts = rangeStr.split("-").map((p) => p.trim());
if (parts.length !== 2) {
throw new Error(`Invalid range format: "${rangeStr}"`);
}
const start = lumiSegmentParseNumber(parts[0], maxSegments, `range start in "${rangeStr}"`);
const end = lumiSegmentParseNumber(parts[1], maxSegments, `range end in "${rangeStr}"`);
if (start > end) {
throw new Error(`Invalid range: "${rangeStr}". Start (${start}) must be less than or equal to end (${end}).`);
}
const segments = [];
for (let i = start; i <= end; i++) {
segments.push(i);
}
return segments;
}
function lumiSegmentParseNumber(str, maxSegments, context = "segment") {
const trimmed = str.trim();
if (!/^[0-9]+$/.test(trimmed)) {
throw new Error(`Invalid ${context}: "${str}". Must be a positive integer.`);
}
const num = Number.parseInt(trimmed, 10);
if (Number.isNaN(num)) {
throw new Error(`Invalid ${context}: "${str}". Could not parse as a number.`);
}
if (num < 1 || num > maxSegments) {
throw new Error(`${context} out of range: ${num}. Valid range is 1-${maxSegments}.`);
}
return num;
}
exports.manufacturerCode = 0x115f; // TODO: from Zcl
const manufacturerOptions = {
lumi: { manufacturerCode: exports.manufacturerCode, disableDefaultResponse: true },
};
exports.lumiModernExtend = {
addManuSpecificLumiCluster: () => modernExtend.deviceAddCustomCluster("manuSpecificLumi", {
name: "manuSpecificLumi",
ID: 0xfcc0,
manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.LUMI_UNITED_TECHOLOGY_LTD_SHENZHEN,
attributes: {
mode: { name: "mode", ID: 0x0009, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
illuminance: { name: "illuminance", ID: 0x0112, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff },
displayUnit: { name: "displayUnit", ID: 0x0114, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
movement: { name: "movement", ID: 0x0118, type: zigbee_herdsman_1.Zcl.DataType.UINT8 },
airQuality: { name: "airQuality", ID: 0x0129, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
curtainReverse: { name: "curtainReverse", ID: 0x0400, type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN, write: true },
curtainHandOpen: { name: "curtainHandOpen", ID: 0x0401, type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN, write: true },
curtainCalibrated: { name: "curtainCalibrated", ID: 0x0402, type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN, write: true },
},
commands: {},
commandsResponse: {},
}),
lumiLight: (args) => {
args = { powerOutageCount: true, deviceTemperature: true, ...args };
const colorTemp = args.colorTemp ? { startup: false, range: args.colorTempRange ?? [153, 370] } : undefined;
const result = modernExtend.light({ effect: false, powerOnBehavior: false, ...args, colorTemp });
result.fromZigbee.push(exports.fromZigbee.lumi_bulb_interval, fz.ignore_occupancy_report, fz.ignore_humidity_report, fz.ignore_pressure_report, fz.ignore_temperature_report, exports.fromZigbee.lumi_specific);
if (args.powerOutageCount)
result.exposes.push(e.power_outage_count());
if (args.deviceTemperature)
result.exposes.push(e.device_temperature());
if (args.powerOutageMemory === "switch") {
result.toZigbee.push(exports.toZigbee.lumi_switch_power_outage_memory);
result.exposes.push(e.power_outage_memory());
}
else if (args.powerOutageMemory === "light") {
result.toZigbee.push(exports.toZigbee.lumi_light_power_outage_memory);
result.exposes.push(e.power_outage_memory().withAccess(ea.STATE_SET));
}
else if (args.powerOutageMemory === "enum") {
const extend = exports.lumiModernExtend.lumiPowerOnBehavior({ lookup: { on: 0, previous: 1, off: 2 } });
result.toZigbee.push(...extend.toZigbee);
result.exposes.push(...extend.exposes);
}
return result;
},
lumiDimmingRangeMin: (args) => modernExtend.numeric({
name: "dimming_range_minimum",
cluster: "manuSpecificLumi",
attribute: { ID: 0x0515, type: 0x20 },
description: "Minimum allowed dimming value",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
unit: "%",
valueMin: 1,
valueMax: 99,
valueStep: 1,
entityCategory: "config",
...args,
}),
lumiDimmingRangeMax: (args) => modernExtend.numeric({
name: "dimming_range_maximum",
cluster: "manuSpecificLumi",
attribute: { ID: 0x0516, type: 0x20 },
description: "Maximum allowed dimming value",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
unit: "%",
valueMin: 2,
valueMax: 100,
valueStep: 1,
entityCategory: "config",
...args,
}),
lumiOffOnDuration: (args) => modernExtend.numeric({
name: "off_on_duration",
cluster: "genLevelCtrl",
attribute: { ID: 0x0012, type: 0x21 },
description: "Duration for light to gradually brighten when turning on",
unit: "s",
valueMin: 0,
valueMax: 10,
valueStep: 0.5,
scale: 10,
entityCategory: "config",
...args,
}),
lumiOnOffDuration: (args) => modernExtend.numeric({
name: "on_off_duration",
cluster: "genLevelCtrl",
attribute: { ID: 0x0013, type: 0x21 },
description: "Duration for light to gradually dim when turning off",
unit: "s",
valueMin: 0,
valueMax: 10,
valueStep: 0.5,
scale: 10,
entityCategory: "config",
...args,
}),
lumiTransitionCurveCurvature: (args) => modernExtend.numeric({
name: "transition_curve_curvature",
cluster: "manuSpecificLumi",
attribute: { ID: 0x0528, type: 0x39 },
description: "Transition curve shape: 0.2-1 (fast to slow), 1 (uniform), 1-6 (slow to fast)",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
valueMin: 0.2,
valueMax: 6,
valueStep: 0.01,
entityCategory: "config",
...args,
}),
lumiTransitionInitialBrightness: (args) => modernExtend.numeric({
name: "transition_initial_brightness",
cluster: "manuSpecificLumi",
attribute: { ID: 0x052c, type: 0x20 },
description: "Starting brightness level when light turns on before transition",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
unit: "%",
valueMin: 0,
valueMax: 50,
valueStep: 1,
entityCategory: "config",
...args,
}),
lumiRGBEffect: (lookup, args) => {
return modernExtend.enumLookup({
name: "effect",
lookup: lookup,
cluster: "manuSpecificLumi",
attribute: { ID: 0x051f, type: 0x23 },
description: "RGB dynamic effect type",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode, disableDefaultResponse: true },
...args,
});
},
lumiRGBEffectSpeed: (args) => {
return modernExtend.numeric({
name: "effect_speed",
cluster: "manuSpecificLumi",
attribute: { ID: 0x0520, type: 0x20 },
description: "RGB dynamic effect speed (1-100%)",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode, disableDefaultResponse: true },
unit: "%",
valueMin: 1,
valueMax: 100,
valueStep: 1,
...args,
});
},
lumiRGBEffectColors: () => {
return {
isModernExtend: true,
toZigbee: [
{
key: ["effect_colors"],
convertSet: async (entity, key, value, meta) => {
const colors = value ||
meta.state.effect_colors || [
{ r: 255, g: 0, b: 0 },
{ r: 0, g: 255, b: 0 },
{ r: 0, g: 0, b: 255 },
];
if (!Array.isArray(colors) || colors.length < 1 || colors.length > 8) {
throw new Error("Must provide array of 1-8 RGB color objects");
}
const colorBytes = [];
for (const color of colors) {
const encoded = lumiEncodeRgbColor(color);
colorBytes.push(...encoded);
}
const packet = node_buffer_1.Buffer.from([0x00, colors.length, ...colorBytes]);
const targetEndpoint = meta.device.getEndpoint(1);
await targetEndpoint.write("manuSpecificLumi", { 1315: { value: packet, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode, disableDefaultResponse: true });
return {
state: {
effect_colors: colors,
},
};
},
},
],
exposes: [
exposes
.list("effect_colors", ea.SET, exposes
.composite("color", "color", ea.SET)
.withFeature(exposes.numeric("r", ea.SET).withValueMin(0).withValueMax(255).withDescription("Red (0-255)"))
.withFeature(exposes.numeric("g", ea.SET).withValueMin(0).withValueMax(255).withDescription("Green (0-255)"))
.withFeature(exposes.numeric("b", ea.SET).withValueMin(0).withValueMax(255).withDescription("Blue (0-255)")))
.withDescription("Array of RGB color objects for dynamic effects (1-8 colors).")
.withLengthMin(1)
.withLengthMax(8)
.withCategory("config"),
],
};
},
lumiT1MEffect: () => {
return {
isModernExtend: true,
toZigbee: [
{
key: ["effect"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value);
const lookup = { flow1: 0, flow2: 1, fading: 2, hopping: 3, breathing: 4, rolling: 5 };
if (!(value in lookup)) {
throw new Error(`Invalid effect: ${value}. Must be one of: ${Object.keys(lookup).join(", ")}`);
}
const effectValue = lookup[value];
const endpoint = meta.device.getEndpoint(1);
await endpoint.write("manuSpecificLumi", { 1311: { value: effectValue, type: 0x23 } }, { manufacturerCode: exports.manufacturerCode, disableDefaultResponse: true });
return { state: { effect: value } };
},
},
],
exposes: [
exposes
.enum("effect", ea.SET, ["flow1", "flow2", "fading", "hopping", "breathing", "rolling"])
.withDescription("RGB dynamic effect type")
.withCategory("config"),
],
};
},
lumiSegmentColors: () => {
return {
isModernExtend: true,
toZigbee: [
{
key: ["segment_colors"],
convertSet: async (entity, key, value, meta) => {
if (!Array.isArray(value) || value.length === 0) {
throw new Error("segment_colors must be a non-empty array");
}
const model = meta.device.modelID;
const deviceType = model === "lumi.light.acn132" ? "strip" : "t1m";
let maxSegments;
if (model === "lumi.light.acn031") {
maxSegments = 20;
}
else if (model === "lumi.light.acn032") {
maxSegments = 26;
}
else if (model === "lumi.light.acn132") {
maxSegments = Math.round((meta.state.length !== undefined ? Number(meta.state.length) : 2) * 5);
}
else {
maxSegments = 26;
}
const brightness = meta.state && meta.state.brightness !== undefined ? Number(meta.state.brightness) : 254;
const colorGroups = {};
for (const item of value) {
if (!item.segment || !item.color) {
throw new Error(`Each segment must have "segment" (1-${maxSegments}) and "color" {r, g, b} fields`);
}
const segment = Number(item.segment);
const color = item.color;
if (segment < 1 || segment > maxSegments) {
throw new Error(`Invalid segment: ${segment}. Must be 1-${maxSegments}`);
}
if (typeof color !== "object" || color.r === undefined || color.g === undefined || color.b === undefined) {
throw new Error(`Invalid color for segment ${segment}. Expected {r, g, b}`);
}
const colorKey = JSON.stringify({ r: color.r, g: color.g, b: color.b });
if (!colorGroups[colorKey]) {
colorGroups[colorKey] = {
color: color,
segments: [],
};
}
colorGroups[colorKey].segments.push(segment);
}
const groups = Object.values(colorGroups);
const ATTR_SEGMENT_CONTROL = deviceType === "t1m" ? 1314 : 1319;
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
const packet = lumiBuildSegmentPacket(group.segments, group.color, deviceType, maxSegments, brightness);
await entity.write("manuSpecificLumi", { [ATTR_SEGMENT_CONTROL]: { value: node_buffer_1.Buffer.from(packet), type: 0x41 } }, { manufacturerCode: exports.manufacturerCode, disableDefaultResponse: true });
}
if (deviceType === "strip") {
return { state: { segment_colors: value, state: "ON" } };
}
return { state: { segment_colors: value } };
},
},
],
exposes: [
exposes
.list("segment_colors", ea.SET, exposes
.composite("segment_color", "segment_color", ea.SET)
.withFeature(exposes.numeric("segment", ea.SET).withDescription("Segment number"))
.withFeature(exposes
.composite("color", "color", ea.SET)
.withFeature(exposes.numeric("r", ea.SET).withValueMin(0).withValueMax(255).withDescription("Red (0-255)"))
.withFeature(exposes.numeric("g", ea.SET).withValueMin(0).withValueMax(255).withDescription("Green (0-255)"))
.withFeature(exposes.numeric("b", ea.SET).withValueMin(0).withValueMax(255).withDescription("Blue (0-255)"))
.withDescription("RGB color object")))
.withDescription("Set individual segment colors.")
.withCategory("config"),
],
};
},
lumiRGBEffectSegments: () => {
return {
isModernExtend: true,
toZigbee: [
{
key: ["effect_segments"],
convertSet: async (entity, key, value, meta) => {
const maxSegments = Math.round((meta.state.length !== undefined ? Number(meta.state.length) : 2) * 5);
if (typeof value !== "string" && value !== undefined && value !== null) {
throw new Error(`effect_segments must be a string, got ${typeof value}`);
}
const segments = lumiSegmentParsePattern(value, maxSegments);
const mask = node_buffer_1.Buffer.from(lumiGenerateSegmentMask(segments, "strip", maxSegments));
await entity.write("manuSpecificLumi", { 1328: { value: mask, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode, disableDefaultResponse: true });
return { state: { effect_segments: value } };
},
},
],
exposes: [
exposes
.text("effect_segments", ea.SET)
.withDescription("Segment selection for effects. Formats: '1,2,5' (list), '1-10' (range), " +
"'1-5,10-15' (multiple ranges), '1,3,5-8,10' (mixed), " +
"'odd/even/first-half/last-half/first-third/middle-third/last-third' (patterns). " +
"Empty = all segments. Each meter = 5 segments (20cm each).")
.withCategory("config"),
],
};
},
lumiOnOff: (args) => {
args = { operationMode: false, lockRelay: false, ...args };
const result = modernExtend.onOff({ powerOnBehavior: false, ...args });
result.fromZigbee.push(exports.fromZigbee.lumi_specific);
result.exposes.push(e.device_temperature(), e.power_outage_count());
if (args.powerOutageMemory === "binary") {
const extend = exports.lumiModernExtend.lumiPowerOutageMemory();
result.toZigbee.push(...extend.toZigbee);
result.exposes.push(...extend.exposes);
}
else if (args.powerOutageMemory === "enum") {
const extend = exports.lumiModernExtend.lumiPowerOnBehavior();
result.toZigbee.push(...extend.toZigbee);
result.exposes.push(...extend.exposes);
}
if (args.operationMode === true) {
const extend = exports.lumiModernExtend.lumiOperationMode({ description: "Decoupled mode for a button" });
if (args.endpointNames) {
args.endpointNames.forEach((ep) => {
const epExtend = exports.lumiModernExtend.lumiOperationMode({
description: `Decoupled mode for ${ep.toString()} button`,
endpointName: ep,
});
result.toZigbee.push(...epExtend.toZigbee);
result.exposes.push(...epExtend.exposes);
});
}
else {
result.toZigbee.push(...extend.toZigbee);
result.exposes.push(...extend.exposes);
}
}
if (args.lockRelay) {
const extend = exports.lumiModernExtend.lumiLockRelay();
if (args.endpointNames) {
args.endpointNames.forEach((ep) => {
const epExtend = exports.lumiModernExtend.lumiLockRelay({
description: `Locks ${ep.toString()} relay and prevents it from operating`,
endpointName: ep,
});
result.toZigbee.push(...epExtend.toZigbee);
result.exposes.push(...epExtend.exposes);
});
}
else {
result.toZigbee.push(...extend.toZigbee);
result.exposes.push(...extend.exposes);
}
}
return result;
},
lumiSwitchType: (args) => modernExtend.enumLookup({
name: "switch_type",
lookup: { toggle: 1, momentary: 2, none: 3 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x000a, type: 0x20 },
description: "External switch type",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiMotorSpeed: (args) => modernExtend.enumLookup({
name: "motor_speed",
lookup: { low: 0, medium: 1, high: 2 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x0408, type: 0x20 },
description: "Controls the motor speed",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiCurtainSpeed: (args) => modernExtend.numeric({
name: "curtain_speed",
cluster: "manuSpecificLumi",
attribute: { ID: 0x043b, type: 0x20 },
description: "Speed of curtain movement",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "ALL",
unit: "%",
valueMin: 1,
valueMax: 100,
entityCategory: "config",
...args,
}),
lumiCurtainManualOpenClose: (args) => modernExtend.binary({
name: "manual_open_close",
valueOn: ["ON", 1],
valueOff: ["OFF", 0],
cluster: "manuSpecificLumi",
attribute: "curtainHandOpen",
description: "Gently pull to open/close the curtain automatically",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "ALL",
entityCategory: "config",
...args,
}),
lumiCurtainAdaptivePullingSpeed: (args) => modernExtend.binary({
name: "adaptive_pulling_speed",
valueOn: ["ON", 1],
valueOff: ["OFF", 0],
cluster: "manuSpecificLumi",
attribute: { ID: 0x0442, type: 0x20 },
description: "The faster/slower the curtain is pulled manually, the faster/slower the curtain will move",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "ALL",
entityCategory: "config",
...args,
}),
lumiCurtainManualStop: (args) => modernExtend.binary({
name: "manual_stop",
valueOn: ["ON", 1],
valueOff: ["OFF", 0],
cluster: "manuSpecificLumi",
attribute: { ID: 0x043a, type: 0x10 },
description: "Manually pulling the curtain during operation stops the motor",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "ALL",
entityCategory: "config",
...args,
}),
lumiCurtainReverse: (args) => {
return modernExtend.binary({
name: "reverse_direction",
valueOn: [true, 1],
valueOff: [false, 0],
cluster: "closuresWindowCovering",
attribute: "windowCoveringMode",
description: "Whether the curtain direction is inverted",
access: "ALL",
entityCategory: "config",
...args,
});
},
lumiCurtainStatus: (args) => modernExtend.enumLookup({
name: "status",
lookup: { closing: 0, opening: 1, stopped: 2, blocked: 3 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x0421, type: 0x20 },
description: "Current status of the curtain (Opening, Closing, Stopped, Blocked)",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "STATE",
entityCategory: "diagnostic",
...args,
}),
lumiCurtainLastManualOperation: (args) => modernExtend.enumLookup({
name: "last_manual_operation",
lookup: { open: 1, close: 2, stop: 3 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x0425, type: 0x20 },
description: "Last triggered manual operation",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "STATE",
entityCategory: "diagnostic",
...args,
}),
lumiCurtainPosition: (args) => modernExtend.numeric({
name: "curtain_position",
cluster: "manuSpecificLumi",
attribute: { ID: 0x041f, type: 0x20 },
description: "Current position of the curtain",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "STATE",
unit: "%",
valueMin: 1,
valueMax: 100,
entityCategory: "diagnostic",
...args,
}),
lumiCurtainTraverseTime: (args) => modernExtend.numeric({
name: "traverse_time",
cluster: "manuSpecificLumi",
attribute: { ID: 0x0403, type: 0x20 },
description: "Time in seconds to get from one end to another",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "STATE",
unit: "sec",
entityCategory: "diagnostic",
...args,
}),
lumiCurtainCalibrationStatus: (args) => modernExtend.enumLookup({
name: "calibration_status",
lookup: { not_calibrated: 0, half_calibrated: 1, fully_calibrated: 2 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x0426, type: 0x20 },
description: "Calibration status of the curtain (Not calibrated, Half calibrated, Fully calibrated)",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "STATE",
entityCategory: "diagnostic",
...args,
}),
lumiAqaraH2EuShutterSwitchAction: () => {
return {
isModernExtend: true,
exposes: [e.action(["button_3_single", "button_4_single"])],
fromZigbee: [
{
cluster: "genMultistateInput",
type: ["attributeReport"],
convert: (model, msg, publish, options, meta) => {
const endpoint = msg.endpoint.ID;
const value = msg.data.presentValue;
// Don't map any other actions/endpoint, create ghost events
// https://github.com/Koenkk/zigbee2mqtt/issues/32059
const buttonMap = { 3: "button_3", 4: "button_4" };
if (endpoint in buttonMap && value === 1) {
return { action: `${buttonMap[endpoint]}_single` };
}
return null;
},
},
],
};
},
lumiCurtainCalibrated: (args) => modernExtend.binary({
name: "calibrated",
valueOn: [true, 1],
valueOff: [false, 0],
cluster: "manuSpecificLumi",
attribute: "curtainCalibrated",
description: "Indicates if this device is calibrated",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "STATE",
entityCategory: "diagnostic",
...args,
}),
lumiCurtainIdentifyBeep: (args) => modernExtend.enumLookup({
name: "identify_beep",
lookup: { short: 0, "1_sec": 1, "2_sec": 2 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x0404, type: 0x20 },
description: "Device will beep for chosen time duration",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "ALL",
entityCategory: "config",
...args,
}),
lumiPowerOnBehavior: (args) => modernExtend.enumLookup({
name: "power_on_behavior",
lookup: { on: 0, previous: 1, off: 2, inverted: 3 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x0517, type: 0x20 },
description: "Controls the behavior when the device is powered on after power loss",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiPowerOutageMemory: (args) => modernExtend.binary({
name: "power_outage_memory",
cluster: "manuSpecificLumi",
attribute: { ID: 0x0201, type: 0x10 },
valueOn: [true, 1],
valueOff: [false, 0],
description: "Controls the behavior when the device is powered on after power loss",
access: "ALL",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiOperationMode: (args) => modernExtend.enumLookup({
name: "operation_mode",
lookup: { decoupled: 0, control_relay: 1 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x0200, type: 0x20 },
description: "Decoupled mode for relay",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiAction: (args) => modernExtend.actionEnumLookup({
actionLookup: { single: 1 },
cluster: "genMultistateInput",
attribute: "presentValue",
...args,
}),
lumiStaticStateAction: () => {
const converter = {
cluster: "manuSpecificLumi",
type: ["attributeReport"],
convert: (model, msg, publish, options, meta) => {
if (msg.data["499"] === 1) {
return { action: "static" };
}
},
};
return { fromZigbee: [converter], isModernExtend: true };
},
lumiVoc: (args) => modernExtend.numeric({
name: "voc",
cluster: "genAnalogInput",
attribute: "presentValue",
reporting: { min: "10_SECONDS", max: "1_HOUR", change: 5 },
description: "Measured VOC value",
unit: "ppb",
access: "STATE_GET",
...args,
}),
lumiAirQuality: (args) => modernExtend.enumLookup({
name: "air_quality",
lookup: { excellent: 1, good: 2, moderate: 3, poor: 4, unhealthy: 5, unknown: 0 },
cluster: "manuSpecificLumi",
attribute: "airQuality",
zigbeeCommandOptions: { disableDefaultResponse: true },
description: "Measured air quality",
access: "STATE_GET",
...args,
}),
lumiDisplayUnit: (args) => modernExtend.enumLookup({
name: "display_unit",
lookup: {
mgm3_celsius: 0x00, // mg/m³, °C (default)
ppb_celsius: 0x01, // ppb, °C
mgm3_fahrenheit: 0x10, // mg/m³, °F
ppb_fahrenheit: 0x11, // ppb, °F
},
cluster: "manuSpecificLumi",
attribute: "displayUnit",
zigbeeCommandOptions: { disableDefaultResponse: true },
description: "Units to show on the display",
entityCategory: "config",
...args,
}),
lumiOutageCountRestoreBindReporting: () => {
const fromZigbee = [
{
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: async (model, msg, publish, options, meta) => {
// At least the Aqara TVOC sensor does not send a deviceAnnounce after coming back online.
// The reconfigureReportingsOnDeviceAnnounce modernExtend is not usable because of this,
// there is however an outage counter published in the 'special' buffer data reported
// under the manuSpecificLumi cluster as attribute 247, we simple decode and grab value with ID 5.
// Normal attribute publishing and decoding will be left to the classic fromZigbee or modernExtends.
if (msg.data["247"] !== undefined) {
const dataDecoded = (0, exports.buffer2DataObject)(model, msg.data["247"]);
if (dataDecoded["5"] !== undefined) {
(0, utils_1.assertNumber)(dataDecoded["5"]);
const currentOutageCount = dataDecoded["5"] - 1;
const previousOutageCount = meta.device?.meta?.outageCount ? meta.device.meta.outageCount : 0;
if (currentOutageCount > previousOutageCount) {
logger_1.logger.debug(`Restoring binding and reporting, ${msg.device.ieeeAddr} came back after losing power.`, NS);
// update outageCount in database
meta.device.meta.outageCount = currentOutageCount;
meta.device.save();
// restore binding
for (const endpoint of meta.device.endpoints) {
// restore bindings
for (const b of endpoint.binds) {
try {
await endpoint.bind(b.cluster.name, b.target);
}
catch (error) {
logger_1.logger.debug(`Failed to re-bind ${b.cluster.name} from ${b.target} for ${msg.device.ieeeAddr} (${error})`, NS);
}
}
// restore reporting
for (const c of endpoint.configuredReportings) {
try {
await endpoint.configureReporting(c.cluster.name, [
{
// @ts-expect-error dynamic, expected correct since already applied
attribute: c.attribute.name,
minimumReportInterval: c.minimumReportInterval,
maximumReportInterval: c.maximumReportInterval,
reportableChange: c.reportableChange,
},
]);
}
catch {
logger_1.logger.debug(`Failed to re-setup reporting of ${c.cluster.name}/${c.attribute.name} for ${msg.device.ieeeAddr}.`, NS);
}
}
}
}
}
}
},
},
];
return { fromZigbee, isModernExtend: true };
},
lumiZigbeeOTA: () => {
// Many Lumi devices miss OTA on endpoint 1 even while supporting it.
// https://github.com/Koenkk/zigbee2mqtt/issues/10660
const result = modernExtend.quirkAddEndpointCluster({
endpointID: 1,
outputClusters: ["genOta"],
});
result.ota = true;
return result;
},
lumiPower: (args) => modernExtend.numeric({
name: "power",
cluster: "genAnalogInput",
attribute: "presentValue",
reporting: { min: "10_SECONDS", max: "1_HOUR", change: 5 },
description: "Instantaneous measured power",
unit: "W",
access: "STATE",
entityCategory: "diagnostic",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiElectricityMeter: () => {
const exposes = [e.energy(), e.voltage(), e.current()];
const fromZigbee = [
{
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: async (model, msg, publish, options, meta) => {
return await (0, exports.numericAttributes2Payload)(msg, meta, model, options, msg.data);
},
},
];
return { exposes, fromZigbee, isModernExtend: true };
},
lumiOverloadProtection: (args) => modernExtend.numeric({
name: "overload_protection",
cluster: "manuSpecificLumi",
attribute: { ID: 0x020b, type: 0x39 },
description: "Maximum allowed load, turns off if exceeded",
valueMin: 100,
valueMax: 3840,
unit: "W",
access: "ALL",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiLedIndicator: (args) => modernExtend.binary({
name: "led_indicator",
cluster: "manuSpecificLumi",
attribute: { ID: 0x0203, type: 0x10 },
valueOn: ["ON", 1],
valueOff: ["OFF", 0],
description: "LED indicator",
access: "ALL",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiLedDisabledNight: (args) => modernExtend.binary({
name: "led_disabled_night",
cluster: "manuSpecificLumi",
attribute: { ID: 0x0203, type: 0x10 },
valueOn: [true, 1],
valueOff: [false, 0],
description: "Enables/disables LED indicator at night",
access: "ALL",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
reporting: false,
...args,
}),
lumiLedDisabledNightTime: () => {
return {
isModernExtend: true,
exposes: [
e.text("schedule_start_time", ea.ALL).withDescription("LED disable schedule start time (HH:MM format)"),
e.text("schedule_end_time", ea.ALL).withDescription("LED disable schedule end time (HH:MM format)"),
],
fromZigbee: [
{
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data["574"] !== undefined) {
const rawValue = msg.data["574"];
const decoded = decodeTimeFormat(rawValue);
return {
schedule_start_time: decoded ? decoded.startTime : "--:--",
schedule_end_time: decoded ? decoded.endTime : "--:--",
schedule_time_raw: rawValue,
};
}
},
},
],
toZigbee: [
{
key: ["schedule_start_time", "schedule_end_time"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value);
// Read current and replace the attribute being edited
const newData = {
schedule_start_time: meta.state?.schedule_start_time,
schedule_end_time: meta.state?.schedule_end_time,
schedule_time_raw: meta.state?.schedule_time_raw,
};
newData[key] = value;
const startTime = newData.schedule_start_time != null && typeof newData.schedule_start_time === "string"
? newData.schedule_start_time
: "00:00";
const endTime = newData.schedule_end_time != null && typeof newData.schedule_end_time === "string" ? newData.schedule_end_time : "00:00";
// Encode and write
const encodedValue = encodeTimeFormat(startTime, endTime);
newData.schedule_time_raw = encodedValue;
await entity.write("manuSpecificLumi", { 574: { value: encodedValue, type: 0x0023 } }, { manufacturerCode: exports.manufacturerCode });
return { state: newData };
},
convertGet: async (entity, key, meta) => {
const endpoint = meta.device.getEndpoint(1);
await endpoint.read("manuSpecificLumi", [0x023e], { manufacturerCode: exports.manufacturerCode });
},
},
],
};
},
lumiButtonLock: (args) => modernExtend.binary({
name: "button_lock",
cluster: "manuSpecificLumi",
attribute: { ID: 0x0200, type: 0x20 },
valueOn: ["ON", 0],
valueOff: ["OFF", 1],
description: "Disables the physical switch button",
access: "ALL",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiFlipIndicatorLight: (args) => modernExtend.binary({
name: "flip_indicator_light",
cluster: "manuSpecificLumi",
attribute: { ID: 0x00f0, type: 0x20 },
valueOn: ["ON", 1],
valueOff: ["OFF", 0],
description: "After turn on, the indicator light turns on while switch is off, and vice versa",
access: "ALL",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
reporting: false,
...args,
}),
lumiPreventReset: () => {
const converter = {
cluster: "genBasic",
type: ["attributeReport"],
convert: (model, msg, publish, options, meta) => {
if (!msg.data[0xfff0] ||
// eg: [0xaa, 0x10, 0x05, 0x41, 0x87, 0x01, 0x01, 0x10, 0x00]
!msg.data[0xfff0].slice(0, 5).equals(node_buffer_1.Buffer.from([0xaa, 0x10, 0x05, 0x41, 0x87]))) {
return;
}
const payload = {
[0xfff0]: {
value: [0xaa, 0x10, 0x05, 0x41, 0x47, 0x01, 0x01, 0x10, 0x01],
type: 0x41,
},
};
msg.device
.getEndpoint(1)
.write("genBasic", payload, { manufacturerCode: exports.manufacturerCode })
.catch((error) => logger_1.logger.error(`Failed to prevent reset of '${msg.device.ieeeAddr}' (${error})`, NS));
},
};
return { fromZigbee: [converter], isModernExtend: true };
},
lumiClickMode: (args) => modernExtend.enumLookup({
name: "click_mode",
lookup: { fast: 1, multi: 2 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x0125, type: 0x20 },
description: "Click mode for wireless button. fast: only supports single click but allows faster response time." +
"multi: supports multiple types of clicks but is slower, because it awaits multiple clicks.",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiSlider: () => {
const fromZigbee = [
{
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data[652] !== undefined) {
const actionLookup = {
1: "slider_single",
2: "slider_double",
3: "slider_hold",
4: "slider_up",
5: "slider_down",
};
return {
action_slide_time: msg.data[561],
action_slide_speed: msg.data[562],
action_slide_relative_displacement: msg.data[563],
action: actionLookup[msg.data[652]],
action_slide_time_delta: msg.data[769],
};
}
},
},
];
const exposes = [
e.numeric("action_slide_time", ea.STATE).withUnit("ms").withCategory("diagnostic"),
e.numeric("action_slide_speed", ea.STATE).withUnit("mm/s").withCategory("diagnostic"),
e.numeric("action_slide_relative_displacement", ea.STATE).withCategory("diagnostic"),
e.numeric("action_slide_time_delta", ea.STATE).withUnit("ms").withCategory("diagnostic"),
// action is exposed from extraActions inside lumiAction
];
return { fromZigbee, exposes, isModernExtend: true };
},
lumiLockRelay: (args) => modernExtend.binary({
name: "lock_relay",
cluster: "manuSpecificLumi",
attribute: { ID: 0x0285, type: 0x20 },
valueOn: [true, 1],
valueOff: [false, 0],
description: "Locks relay and prevents it from operating",
access: "ALL",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiSetEventMode: () => {
// I have no idea, why it is used everywhere, even if not supported
// modes:
// 0 - 'command' mode. keys send commands. useful for binding
// 1 - 'event' mode. keys send events. useful for handling
const configure = [
async (device, coordinatorEndpoint, definition) => {
await device
.getEndpoint(1)
.write("manuSpecificLumi", { mode: 1 }, { manufacturerCode: exports.manufacturerCode, disableResponse: true });
},
];
return { configure, isModernExtend: true };
},
lumiSwitchMode: (args) => modernExtend.enumLookup({
name: "mode_switch",
lookup: { quick_mode: 1, anti_flicker_mode: 4 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x0004, type: 0x21 },
description: "Anti flicker mode can be used to solve blinking issues of some lights. Quick mode makes the device respond faster.",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiVibration: () => {
const exposes = [e.action(["shake", "triple_strike", "movement"])];
const fromZigbee = [
{
cluster: "ssIasZone",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data[45] !== undefined) {
const zoneStatus = msg.data[45];
const actionLookup = { 1: "shake", 2: "triple_strike" };
return { action: actionLookup[zoneStatus] };
}
},
},
{
cluster: "genMultistateInput",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data.presentValue !== undefined && msg.data.presentValue === 1) {
return { action: "triple_strike" };
}
},
},
{
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data.movement !== undefined && msg.data.movement === 1) {
return { action: "movement" };
}
},
},
];
return { exposes, fromZigbee, isModernExtend: true };
},
lumiSensitivityAdjustment: (args) => modernExtend.enumLookup({
name: "sensitivity_adjustment",
lookup: { high: 1, medium: 2, low: 3 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x010e, type: 0x20 },
description: "Sensitivity adjustment for the device",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "SET",
entityCategory: "config",
...args,
}),
lumiReportInterval: (args) => modernExtend.enumLookup({
name: "report_interval",
lookup: { "1s": 0x01, "5s": 0x02, "10s": 0x03 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x0110, type: 0x20 },
description: "Reporting interval for the device",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
access: "SET",
entityCategory: "config",
...args,
}),
lumiMiscellaneous: (args) => {
args = { cluster: "manuSpecificLumi", deviceTemperatureAttribute: 3, powerOutageCountAttribute: 5, resetsWhenPairing: false, ...args };
const exposes = [e.device_temperature(), e.power_outage_count(args.resetsWhenPairing)];
const fromZigbee = [
{
cluster: args.cluster,
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const payload = {};
if (msg.data[args.deviceTemperatureAttribute] !== undefined) {
const value = msg.data[args.deviceTemperatureAttribute];
(0, utils_1.assertNumber)(value);
payload.device_temperature = value;
}
if (msg.data[args.powerOutageCountAttribute] !== undefined) {
const value = msg.data[args.powerOutageCountAttribute];
(0, utils_1.assertNumber)(value);
payload.power_outage_count = value - 1;
}
return payload;
},
},
];
return { exposes, fromZigbee, isModernExtend: true };
},
lumiKnobRotation: (args) => {
const withButtonState = args?.withButtonState || true;
const exposes = [
e.action(["start_rotating", "rotation", "stop_rotating"]),
e.numeric("action_rotation_angle", ea.STATE).withUnit("*").withDescription("Rotation angle").withCategory("diagnostic"),
e.numeric("action_rotation_angle_speed", ea.STATE).withUnit("*").withDescription("Rotation angle speed").withCategory("diagnostic"),
e.numeric("action_rotation_percent", ea.STATE).withUnit("%").withDescription("Rotation percent").withCategory("diagnostic"),
e.numeric("action_rotation_percent_speed", ea.STATE).withUnit("%").withDescription("Rotation percent speed").withCategory("diagnostic"),
e.numeric("action_rotation_time", ea.STATE).withUnit("ms").withDescription("Rotation time").withCategory("diagnostic"),
];
if (withButtonState) {
exposes.push(e
.enum("action_rotation_button_state", ea.STATE, ["released", "pressed"])
.withDescription("Button state during rotation")
.withCategory("diagnostic"));
}
const fromZigbee = [
{
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data[570] !== undefined) {
const act = { 1: "start_rotating", 2: "rotation", 3: "stop_rotating" };
const state = { 0: "released", 128: "pressed" };
return {
action: act[msg.data[570] & ~128],
action_rotation_button_state: state[msg.data[570] & 128],
action_rotation_angle: msg.data[558],
action_rotation_angle_speed: msg.data[560],
action_rotation_percent: msg.data[563],
action_rotation_percent_speed: msg.data[562],
action_rotation_time: msg.data[561],
};
}
},
},
];
return { exposes, fromZigbee, isModernExtend: true };
},
lumiCommandMode: (args) => {
args = { setEventMode: true, ...args };
const exposes = [
e
.enum("operation_mode", ea.ALL, ["event", "command"])
.withDescription("Command mode is useful for binding. Event mode is useful for processing."),
];
const toZigbee = [
{
key: ["operation_mode"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value);
// modes:
// 0 - 'command' mode. keys send commands. useful for binding
// 1 - 'event' mode. keys send events. useful for handling
const lookup = { command: 0, event: 1 };
const endpoint = meta.device.getEndpoint(1);
await endpoint.write("manuSpecificLumi", { mode: (0, utils_1.getFromLookup)(value.toLowerCase(), lookup) }, { manufacturerCode: manufacturerOptions.lumi.manufacturerCode });
return { state: { operation_mode: value.toLowerCase() } };
},
convertGet: async (entity, key, meta) => {
const endpoint = meta.device.getEndpoint(1);
await endpoint.read("manuSpecificLumi", ["mode"], {
manufacturerCode: manufacturerOptions.lumi.manufacturerCode,
});
},
},
];
const result = { exposes, toZigbee, isModernExtend: true };
if (args.setEventMode) {
result.configure = exports.lumiModernExtend.lumiSetEventMode().configure;
}
return result;
},
lumiBattery: (args) => {
args = {
cluster: "manuSpecificLumi",
percentageAttribute: 1,
voltageAttribute: 1,
...args,
};
const exposes = [e.battery(), e.battery_voltage()];
const fromZigbee = [
{
cluster: args.cluster,
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const payload = {};
const lookup = numericAttributes2Lookup(model, msg.data);
if (lookup[args.percentageAttribute.toString()]) {
const value = lookup[args.percentageAttribute];
(0, utils_1.assertNumber)(value);
if (!args.voltageToPercentage)
payload.battery = value;
}
if (lookup[args.voltageAttribute.toString()]) {
const value = lookup[args.voltageAttribute];
(0, utils_1.assertNumber)(value);
payload.voltage = value;
if (args.voltageToPercentage)
payload.battery = (0, utils_1.batteryVoltageToPercentage)(value, args.voltageToPercentage);
}
return payload;
},
},
];
return { exposes, fromZigbee, isModernExtend: true };
},
fp300PIRDetection: () => {
const attribute = { ID: 0x014d, type: 0x20 }; // Attribute: 333
return modernExtend.binary({
name: "pir_detection",
valueOn: [true, 1],
valueOff: [false, 0],
access: "STATE_GET",
cluster: "manuSpecificLumi",
attribute: attribute,
description: "Indicates whether the PIR sensor detects motion (in mmWave + PIR mode after mmWave presence detection PIR sensors gets turned off so this attribute might change to false although the presence is detected).",
});
},
fp300BatteryPoll: () => {
// FP300 firmware 0.0.0_6542 no longer pushes the 0x00F7 struct that carries the battery data
// (https://github.com/Koenkk/zigbee2mqtt/issues/32153), but it still answers an explicit read while awake.
// Rather than gating on a firmware version, track when the struct was last received and only read it back
// once it goes stale: firmware that pushes the struct by itself keeps this poll dormant, and on affected
// firmware the read response refreshes the timestamp, so reads self-regulate to ~structMaxAgeMs.
// The FP300 is a sleepy end device: the read is queued (`sendPolicy: "queue"`) and flushed by the request
// queue when the device next wakes; quirkCheckinInterval() in the definition gives the queue its lifetime.
const structMaxAgeMs = 4 * 60 * 60 * 1000;
const storeKey = "lumi_struct_last_received";
const structReceived = {
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg) => {
if (msg.data[0x00f7] !== undefined)
globalStore.putValue(msg.device, storeKey, Date.now());
},
};
return {
...modernExtend.poll({
key: "battery",
defaultIntervalSeconds: 60 * 60,
poll: (device) => {
if (Date.now() - globalStore.getValue(device, storeKey, 0) < structMaxAgeMs)
return;
device
.getEndpoint(1)
.read("manuSpecificLumi", [0x00f7], { manufacturerCode: exports.manufacturerCode, sendPolicy: "queue" })
.catch((error) => logger_1.logger.debug(`Failed to read battery of '${device.ieeeAddr}' (${error})`, NS));
},
}),
fromZigbee: [structReceived],
};
},
fp300DetectionRange: (args) => {
args = {
rangeOffset: 0.25,
rangesCount: 24,
...args,
};
let detectionRangeComposite = e
.composite("detection_range_composite", "detection_range_composite", ea.ALL)
.withDescription("Specifies the detection range using set of boolean settings.");
for (let i = 0; i < args.rangesCount; ++i) {
detectionRangeComposite = detectionRangeComposite.withFeature(e
.binary(`detection_range_${i}`, ea.SET, true, false)
.withDescription(`${(i * args.rangeOffset).toFixed(2)}m - ${((i + 1) * args.rangeOffset).toFixed(2)}m`));
}
return {
isModernExtend: true,
exposes: [
e
.numeric("detection_range", ea.ALL)
.withValueMin(0)
.withValueMax((1 << 24) - 1)
.withValueStep(1)
.withDescription("Specifies the range that is being detected. Requires mmWave radar mode. Press the on-device button to wake the device up and refresh its' settings."),
detectionRangeComposite,
],
fromZigbee: [
{
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data["410"] && node_buffer_1.Buffer.isBuffer(msg.data["410"])) {
const buffer = msg.data["410"];
const detection_range_value = buffer.length > 0 ? buffer.readUIntLE(2, 3) : 0xffffff;
return {
detection_range_prefix: buffer.length > 0 ? buffer.readUIntLE(0, 2) : 0x0300,
detection_range: detection_range_value,
detection_range_composite: encodeDetectionRangeComposite(detection_range_value, args.rangesCount),
};
}
},
},
],
toZigbee: [
{
key: ["detection_range"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertNumber)(value);
const detection_range_prefix = meta.state?.detection_range_prefix != null && typeof meta.state?.detection_range_prefix === "number"
? meta.state?.detection_range_prefix
: 0x0300;
const buffer = node_buffer_1.Buffer.allocUnsafe(5);
buffer.writeUIntLE(detection_range_prefix, 0, 2);
buffer.writeUIntLE(value, 2, 3);
await entity.write("manuSpecificLumi", {
410: { value: buffer, type: 0x41 },
}, { manufacturerCode: exports.manufacturerCode });
return {
state: {
detection_range: value,
detection_range_composite: encodeDetectionRangeComposite(value, args.rangesCount),
},
};
},
convertGet: async (entity, key, meta) => {
const endpoint = meta.device.getEndpoint(1);
await endpoint.read("manuSpecificLumi", [0x019a], { manufacturerCode: exports.manufacturerCode });
},
},
{
key: ["detection_range_composite"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertObject)(value);
const detection_range_prefix = meta.state?.detection_range_prefix != null && typeof meta.state?.detection_range_prefix === "number"
? meta.state?.detection_range_prefix
: 0x0300;
const detection_range_value = decodeDetectionRangeComposite(value, args.rangesCount);
const buffer = node_buffer_1.Buffer.allocUnsafe(5);
buffer.writeUIntLE(detection_range_prefix, 0, 2);
buffer.writeUIntLE(detection_range_value, 2, 3);
await entity.write("manuSpecificLumi", { 410: { value: buffer, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
return {
state: {
detection_range: detection_range_value,
detection_range_composite: value,
},
};
},
convertGet: async (entity, key, meta) => {
const endpoint = meta.device.getEndpoint(1);
await endpoint.read("manuSpecificLumi", [0x019a], { manufacturerCode: exports.manufacturerCode });
},
},
],
};
},
fp300TrackDistance: () => {
return {
isModernExtend: true,
exposes: [
e.enum("track_target_distance", ea.SET, ["start_tracking_distance"]).withDescription("Initiate current target distance tracking."),
],
toZigbee: [
{
key: ["track_target_distance"],
convertSet: async (entity, key, value, meta) => {
// Uint8: 1 (0x08) attribute 0x0198 (attribute: 408)
await entity.write("manuSpecificLumi", { 408: { value: 1, type: 0x20 } }, { manufacturerCode: exports.manufacturerCode });
},
},
],
};
},
fp1eAIInterference: () => {
const attribute = { ID: 0x015e, type: 0x20 }; // Attribute: 350
return modernExtend.binary({
name: "ai_interference_source_selfidentification",
valueOn: ["ON", 1],
valueOff: ["OFF", 0],
cluster: "manuSpecificLumi",
attribute: attribute,
description: "AI interference source self-identification switch, when enabled can identify fans, air conditioners and other interference sources",
access: "ALL",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
});
},
fp1eAdaptiveSensitivity: () => {
const attribute = { ID: 0x015d, type: 0x20 }; // Attribute: 349
return modernExtend.binary({
name: "ai_sensitivity_adaptive",
valueOn: ["ON", 1],
valueOff: ["OFF", 0],
cluster: "manuSpecificLumi",
attribute: attribute,
description: "Adaptive sensitivity switch function.",
access: "ALL",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
});
},
fp1ePresence: () => {
const attribute = { ID: 0x0142, type: 0x20 };
return modernExtend.binary({
name: "presence",
valueOn: [true, 1],
valueOff: [false, 0],
access: "STATE_GET",
cluster: "manuSpecificLumi",
attribute: attribute,
description: "Indicates whether the device detected presence",
});
},
fp1eMovement: () => modernExtend.enumLookup({
name: "movement",
lookup: { unknown_0: 0, unknown_1: 1, no_presence: 2, movement: 3, no_movement: 4 },
cluster: "manuSpecificLumi",
attribute: { ID: 0x0160, type: 0x20 },
zigbeeCommandOptions: { disableDefaultResponse: true },
description: "Is movement detected?",
access: "STATE_GET",
}),
fp1eTargetDistance: () => modernExtend.numeric({
name: "target_distance",
cluster: "manuSpecificLumi",
attribute: { ID: 0x015f, type: 0x23 },
access: "STATE_GET",
description: "Distance to the detected target",
scale: 100,
precision: 2,
unit: "m",
}),
fp1eDetectionRange: () => modernExtend.numeric({
name: "detection_range",
cluster: "manuSpecificLumi",
attribute: { ID: 0x015b, type: 0x23 },
access: "ALL",
description: "The device will monitor presence within the detection range",
scale: 100,
precision: 2,
unit: "m",
valueMin: 0,
valueMax: 6.0,
valueStep: 0.3,
}),
fp1eSpatialLearning: () => {
return {
isModernExtend: true,
exposes: [e.enum("spatial_learning", ea.SET, ["Start Learning"]).withDescription("Initiate AI Spatial Learning.")],
toZigbee: [
{
key: ["spatial_learning"],
convertSet: async (entity, key, value, meta) => {
await entity.write("manuSpecificLumi", { 343: { value: 1, type: 0x20 } }, manufacturerOptions.lumi);
},
},
],
};
},
fp1eRestartDevice: () => {
return {
isModernExtend: true,
exposes: [e.enum("restart_device", ea.SET, ["Restart Device"]).withDescription("Restarts the device.")],
toZigbee: [
{
key: ["restart_device"],
convertSet: async (entity, key, value, meta) => {
await entity.write("manuSpecificLumi", { 232: { value: 0x00, type: 0x10 } }, manufacturerOptions.lumi);
},
},
],
};
},
lumiMultiClick: (args) => modernExtend.binary({
name: "multi_click",
cluster: "manuSpecificLumi",
attribute: { ID: 0x0286, type: 0x20 },
valueOn: [true, 2],
valueOff: [false, 1],
description: "Enable multi-click mode for the switch, otherwise single click",
access: "ALL",
entityCategory: "config",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
...args,
}),
lumiPreventLeave: () => {
const converter = {
cluster: "manuSpecificLumi",
type: ["attributeReport"],
convert: (model, msg, publish, options, meta) => {
if (msg.cluster === "manuSpecificLumi" && msg.data[0x00fc] === false) {
const payload = {
[0x00fc]: {
value: true,
type: 0x10,
},
};
msg.device
.getEndpoint(1)
.write("manuSpecificLumi", payload, { manufacturerCode: exports.manufacturerCode })
.catch((error) => logger_1.logger.error(`Failed to prevent leave of '${msg.device.ieeeAddr}' (${error})`, NS));
}
},
};
return { fromZigbee: [converter], isModernExtend: true };
},
lumiExternalSensor: () => {
return {
isModernExtend: true,
exposes: [
e
.enum("sensor", ea.ALL, ["internal", "external"])
.withDescription("Select mode to display sensor: internal or both with external")
.withCategory("config"),
e
.numeric("external_temperature", ea.STATE_SET)
.withUnit("°C")
.withValueMin(-100)
.withValueMax(100)
.withValueStep(0.1)
.withDescription("Value for external temperature sensor")
.withCategory("config"),
e
.numeric("external_humidity", ea.STATE_SET)
.withUnit("%")
.withValueMin(0)
.withValueMax(100)
.withDescription("Value for external humidity sensor")
.withCategory("config"),
],
toZigbee: [
{
key: ["sensor", "external_temperature", "external_humidity"],
convertSet: async (entity, key, value, meta) => {
const lumiHeader = (counter, length, action) => {
const header = [0xaa, 0x71, length + 3, 0x44, counter];
const integrity = 512 - header.reduce((sum, elem) => sum + elem, 0);
return [...header, integrity, action, 0x41, length];
};
const fictiveSensor = node_buffer_1.Buffer.from("00158d00019d1b98", "hex");
switch (key) {
case "sensor": {
(0, utils_1.assertEndpoint)(entity);
const device = node_buffer_1.Buffer.from(entity.deviceIeeeAddress.substring(2), "hex");
const timestamp = node_buffer_1.Buffer.alloc(4);
timestamp.writeUInt32BE(Date.now() / 1000);
if (value === "external") {
const params1 = [
...timestamp,
0x15,
...device,
...fictiveSensor,
0x00,
0x02,
0x00,
0x55,
0x15,
0x0a,
0x01,
0x00,
0x00,
0x01,
0x06,
0xe6,
0xb9,
0xbf,
0xe5,
0xba,
0xa6,
0x00,
0x00,
0x00,
0x00,
0x00,
0x01,
0x02,
0x08,
0x65,
];
const params2 = [
...timestamp,
0x14,
...device,
...fictiveSensor,
0x00,
0x01,
0x00,
0x55,
0x15,
0x0a,
0x01,
0x00,
0x00,
0x01,
0x06,
0xe6,
0xb8,
0xa9,
0xe5,
0xba,
0xa6,
0x00,
0x00,
0x00,
0x00,
0x00,
0x01,
0x02,
0x07,
0x63,
];
const val1 = [...lumiHeader(0x12, params1.length, 0x02), ...params1];
const val2 = [...lumiHeader(0x13, params2.length, 0x02), ...params2];
await entity.write("manuSpecificLumi", { 65522: { value: val1, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
await entity.write("manuSpecificLumi", { 65522: { value: val2, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
}
else if (value === "internal") {
const params1 = [
...timestamp,
0x15,
...device,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
];
const params2 = [
...timestamp,
0x14,
...device,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
];
const val1 = [...lumiHeader(0x12, params1.length, 0x04), ...params1];
const val2 = [...lumiHeader(0x13, params2.length, 0x04), ...params2];
await entity.write("manuSpecificLumi", { 65522: { value: val1, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
await entity.write("manuSpecificLumi", { 65522: { value: val2, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
}
await entity.read("manuSpecificLumi", [0x172], {
manufacturerCode: exports.manufacturerCode,
});
break;
}
case "external_temperature":
if (meta.state.sensor === "external") {
const temperatureBuf = node_buffer_1.Buffer.alloc(4);
const number = (0, utils_1.toNumber)(value);
temperatureBuf.writeFloatBE(Math.round(number * 100));
const params = [...fictiveSensor, 0x00, 0x01, 0x00, 0x55, ...temperatureBuf];
const data = [...lumiHeader(0x12, params.length, 0x05), ...params];
await entity.write("manuSpecificLumi", { 65522: { value: data, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
return { state: { external_temperature: value } };
}
break;
case "external_humidity":
if (meta.state.sensor === "external") {
const humidityBuf = node_buffer_1.Buffer.alloc(4);
const number = (0, utils_1.toNumber)(value);
humidityBuf.writeFloatBE(Math.round(number * 100));
const params = [...fictiveSensor, 0x00, 0x02, 0x00, 0x55, ...humidityBuf];
const data = [...lumiHeader(0x12, params.length, 0x05), ...params];
await entity.write("manuSpecificLumi", { 65522: { value: data, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
return { state: { external_humidity: value } };
}
break;
default: // Unknown key
logger_1.logger.debug(`Unhandled key ${key}`, "zhc:lumi:externalSensor");
}
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x172], { manufacturerCode: exports.manufacturerCode });
},
},
],
fromZigbee: [
{
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const result = {};
Object.entries(msg.data).forEach(([key, value]) => {
switch (Number.parseInt(key, 10)) {
case 0x0172:
result.sensor = (0, utils_1.getFromLookup)(value, { 2: "external", 0: "internal", 1: "internal", 3: "external" });
break;
case 0xfff2:
logger_1.logger.debug(`Unhandled key ${key} = ${value}`, "zhc:lumi:externalSensor");
break;
default:
logger_1.logger.debug(`Unknown key ${key} = ${value}`, "zhc:lumi:externalSensor");
}
});
return result;
},
},
],
};
},
w600ExternalTempSensor: () => createW600ExternalTempSensor(),
w600Heartbeat: () => createW600Heartbeat(),
w600Thermostat: () => createW600Thermostat(),
w600Schedule: () => createW600Schedule(),
w600WeeklySchedule: () => createW600WeeklySchedule(),
w600PresetTemperatureTable: () => createW600PresetTemperatureTable(),
w600ValvePosition: () => createW600ValvePosition(),
lumiBathroomHeaterT1: () => createLumiBathroomHeaterT1(),
lumiReadPositionOnReport: (type) => {
let converter;
if (type === "genAnalogOutput") {
converter = {
cluster: "genAnalogOutput",
type: ["attributeReport"],
convert: (model, msg, publish, options, meta) => {
// The position (genAnalogOutput.presentValue) reported via an attribute contains an invalid value
// however when reading it will provide the correct value.
msg.device.endpoints[0]
.read("genAnalogOutput", ["presentValue"])
.catch((error) => logger_1.logger.error(`Failed to read position '${msg.device.ieeeAddr}' (${error})`, NS));
},
};
}
else if (type === "genMultistateOutput") {
converter = {
cluster: "genMultistateOutput",
type: ["attributeReport"],
convert: (model, msg, publish, options, meta) => {
if (msg.data.presentValue !== undefined && msg.data.presentValue > 1) {
// Try to read the position after the motor stops, the device occasionally report wrong data right after stopping
// Might need to add delay, seems to be working without one but needs more tests.
msg.device
.getEndpoint(1)
.read("genAnalogOutput", ["presentValue"])
.catch((error) => logger_1.logger.error(`Failed to read position '${msg.device.ieeeAddr}' (${error})`, NS));
}
},
};
}
else if (type === "genBasic") {
converter = {
cluster: "genBasic",
type: ["attributeReport"],
convert: (model, msg, publish, options, meta) => {
if (msg.data["1028"] === 0) {
// Try to read the position after the motor stops, the device occasionally report wrong data right after stopping
// Might need to add delay, seems to be working without one but needs more tests.
msg.device
.getEndpoint(1)
.read("genAnalogOutput", ["presentValue"])
.catch((error) => logger_1.logger.error(`Failed to read position '${msg.device.ieeeAddr}' (${error})`, NS));
}
},
};
}
return { fromZigbee: [converter], isModernExtend: true };
},
};
exports.modernExtend = exports.lumiModernExtend;
const YUBA_LUMI_CLUSTER = "manuSpecificLumi";
const YUBA_ATTR_PACKED_STATE = 0x024f;
const YUBA_ATTR_MUTE_PROMPT_TONE = 0x0256;
const YUBA_ATTR_MUTE_PROMPT_TIME = 0x0257;
const YUBA_ATTR_CONSTANT_TEMPERATURE_MODE = 0x02be;
const YUBA_ATTR_NIGHT_LIGHT = 0x0518;
const YUBA_MODE_LOOKUP = { warm: 0, dry: 3, fan_only: 4, exhaust: 5 };
const YUBA_SYSTEM_MODE_LOOKUP = { warm: "heat", dry: "dry", fan_only: "fan_only", exhaust: "fan_only" };
const YUBA_SYSTEM_MODE_TO_OPERATING_MODE = { heat: "warm", dry: "dry", fan_only: "fan_only" };
const YUBA_FAN_LOOKUP = { low: 0, medium: 1, high: 2 };
const YUBA_FAN_STATE_LOOKUP = { low: 0xfffd, medium: 0xfffe, high: 0xffff };
function parseYubaEnabled(value, property) {
const normalized = String(value).toLowerCase();
if (["1", "on", "true"].includes(normalized))
return true;
if (["0", "off", "false"].includes(normalized))
return false;
throw new Error(`${property} must be ON or OFF`);
}
function parseYubaClockTime(value, property) {
const normalized = String(value);
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(normalized)) {
throw new Error(`${property} must use HH:MM in 24-hour format`);
}
const [hour, minute] = normalized.split(":").map(Number);
return hour * 60 + minute;
}
function formatYubaClockTime(minutes) {
return `${Math.floor(minutes / 60)
.toString()
.padStart(2, "0")}:${(minutes % 60).toString().padStart(2, "0")}`;
}
function getYubaLookupKey(lookup, value) {
return Object.entries(lookup).find(([, raw]) => raw === value)?.[0];
}
function serializeYubaBuffer(value) {
if (node_buffer_1.Buffer.isBuffer(value))
return Array.from(value);
if ((0, utils_1.isObject)(value) && value.type === "Buffer" && Array.isArray(value.data))
return value.data.map(Number);
return undefined;
}
function getYubaHeartbeatPackedState(value) {
const bytes = serializeYubaBuffer(value);
if (!bytes)
return;
for (let index = 0; index <= bytes.length - 10; index++) {
if (bytes[index] !== 0x78 || bytes[index + 1] !== zigbee_herdsman_1.Zcl.DataType.UINT64)
continue;
const packed = node_buffer_1.Buffer.from(bytes.slice(index + 2, index + 10)).readBigUInt64LE();
return packed;
}
}
function parseYubaPackedValue(value) {
if (typeof value === "bigint")
return value;
if (typeof value === "number" && Number.isSafeInteger(value))
return BigInt(value);
if (typeof value === "string") {
try {
return BigInt(value);
}
catch {
return;
}
}
const bytes = serializeYubaBuffer(value);
if (bytes?.length === 8)
return node_buffer_1.Buffer.from(bytes).readBigUInt64BE();
}
function decodeYubaPackedState(value) {
const packed = parseYubaPackedValue(value);
if (packed === undefined)
return {};
const result = {};
const targetTemperature = Number((packed >> 48n) & 0xffffn);
const currentTemperature = Number((packed >> 32n) & 0xffffn);
const control = Number((packed >> 24n) & 0xffn);
const fanControl = Number((packed >> 16n) & 0xffn);
if (targetTemperature !== 0xffff)
result.current_heating_setpoint = targetTemperature / 100;
if (currentTemperature !== 0xffff)
result.local_temperature = currentTemperature / 100;
if (control !== 0xff) {
const powered = control >> 4 === 1;
const mode = getYubaLookupKey(YUBA_MODE_LOOKUP, control & 0x0f);
result.heater_power = powered;
if (!powered) {
result.operating_mode = "off";
result.system_mode = "off";
result.running_state = "idle";
result.swing_mode = "off";
}
else if (mode !== undefined) {
result.operating_mode = mode;
result.system_mode = YUBA_SYSTEM_MODE_LOOKUP[mode];
result.running_state = mode === "warm" ? "heat" : "fan_only";
}
}
if (fanControl !== 0xff) {
const fanMode = getYubaLookupKey(YUBA_FAN_LOOKUP, fanControl >> 4);
const swing = fanControl & 0x0f;
if (fanMode !== undefined)
result.fan_mode = fanMode;
if (swing === 0x0c || swing === 0x0d)
result.swing_mode = swing === 0x0c ? "on" : "off";
}
return result;
}
async function readYubaAttributes(entity, attributes) {
(0, utils_1.assertEndpoint)(entity);
return await entity.read(YUBA_LUMI_CLUSTER, attributes, { manufacturerCode: exports.manufacturerCode });
}
async function readYubaAttributeValue(entity, attribute) {
const response = await readYubaAttributes(entity, [attribute]);
const value = response[attribute];
if (value === undefined)
throw new Error(`Aqara Bathroom Heater T1 did not return attribute 0x${attribute.toString(16)}`);
return value;
}
async function readYubaPackedState(entity) {
await readYubaAttributes(entity, [YUBA_ATTR_PACKED_STATE]);
}
async function writeYubaAttribute(entity, attribute, value, type) {
(0, utils_1.assertEndpoint)(entity);
await entity.write(YUBA_LUMI_CLUSTER, { [attribute]: { value, type } }, { manufacturerCode: exports.manufacturerCode, disableDefaultResponse: true });
}
async function writeYubaPackedState(entity, value) {
await writeYubaAttribute(entity, YUBA_ATTR_PACKED_STATE, value, zigbee_herdsman_1.Zcl.DataType.UINT64);
}
function getYubaFanControl(value) {
return Number((value >> 16n) & 0xffn);
}
async function getYubaSwingRaw(entity, meta) {
const packed = parseYubaPackedValue(await readYubaAttributeValue(entity, YUBA_ATTR_PACKED_STATE));
if (packed !== undefined) {
const swing = getYubaFanControl(packed) & 0x0f;
if (swing === 0x0c || swing === 0x0d)
return swing;
}
if (meta.state?.swing_mode === "on")
return 0x0c;
if (meta.state?.swing_mode === "off")
return 0x0d;
throw new Error("Cannot set fan_mode until swing_mode is known");
}
async function getYubaFanMode(entity, meta) {
const packed = parseYubaPackedValue(await readYubaAttributeValue(entity, YUBA_ATTR_PACKED_STATE));
if (packed !== undefined) {
const fanMode = getYubaLookupKey(YUBA_FAN_LOOKUP, getYubaFanControl(packed) >> 4);
if (fanMode !== undefined)
return fanMode;
}
const stateFanMode = String(meta.state?.fan_mode ?? "");
if (stateFanMode in YUBA_FAN_LOOKUP)
return stateFanMode;
throw new Error("Cannot set swing_mode until fan_mode is known");
}
async function safeYubaRead(endpoint, cluster, attributes, options) {
try {
await endpoint.read(cluster, attributes, options);
}
catch (error) {
const details = error instanceof Error ? error.message : String(error);
logger_1.logger.debug(`Aqara Bathroom Heater T1 initialization read failed on ${cluster}: ${details}`, NS);
}
}
function createLumiBathroomHeaterT1() {
const fromYuba = {
cluster: YUBA_LUMI_CLUSTER,
type: ["attributeReport", "readResponse"],
convert: (model, msg) => {
const data = msg.data;
const result = {};
if (data[YUBA_ATTR_CONSTANT_TEMPERATURE_MODE] !== undefined) {
result.constant_temperature_mode = Number(data[YUBA_ATTR_CONSTANT_TEMPERATURE_MODE]) === 1 ? "ON" : "OFF";
}
if (data[YUBA_ATTR_MUTE_PROMPT_TONE] !== undefined) {
result.mute_prompt_tone = Number(data[YUBA_ATTR_MUTE_PROMPT_TONE]) === 1 ? "ON" : "OFF";
}
if (data[YUBA_ATTR_MUTE_PROMPT_TIME] !== undefined) {
const schedule = Number(data[YUBA_ATTR_MUTE_PROMPT_TIME]) >>> 0;
const startMinutes = schedule & 0xffff;
const endMinutes = (schedule >>> 16) & 0xffff;
if (startMinutes < 1440 && endMinutes < 1440) {
result.mute_prompt_start_time = formatYubaClockTime(startMinutes);
result.mute_prompt_end_time = formatYubaClockTime(endMinutes);
}
}
if (data[YUBA_ATTR_NIGHT_LIGHT] !== undefined) {
result.night_light_mode = (Number(data[YUBA_ATTR_NIGHT_LIGHT]) & 1) === 0 ? "ON" : "OFF";
}
const packed = data[YUBA_ATTR_PACKED_STATE] ?? getYubaHeartbeatPackedState(data[0x00f7]);
return packed === undefined ? result : { ...result, ...decodeYubaPackedState(packed) };
},
};
const targetTemperature = {
key: ["current_heating_setpoint"],
convertSet: async (entity, key, value) => {
(0, utils_1.assertNumber)(value, key);
if (value < 16 || value > 45)
throw new Error("current_heating_setpoint must be between 16 and 45");
const encoded = BigInt(Math.round(value * 100));
await writeYubaPackedState(entity, (encoded << 48n) | 0x0000ffffffffffffn);
return { state: { current_heating_setpoint: value } };
},
convertGet: readYubaPackedState,
};
const setOperatingMode = async (entity, mode) => {
if (mode === "off") {
await writeYubaPackedState(entity, 0xffffffff0ffffffcn);
return;
}
if (!(mode in YUBA_MODE_LOOKUP))
throw new Error(`Unsupported operating_mode: ${mode}`);
const control = 0x10 | YUBA_MODE_LOOKUP[mode];
await writeYubaPackedState(entity, 0xffffffff00ffffffn | (BigInt(control) << 24n));
};
const operatingMode = {
key: ["operating_mode"],
convertSet: async (entity, key, value) => {
(0, utils_1.assertString)(value, key);
await setOperatingMode(entity, value);
return { state: { operating_mode: value } };
},
convertGet: readYubaPackedState,
};
const systemMode = {
key: ["system_mode"],
convertSet: async (entity, key, value) => {
(0, utils_1.assertString)(value, key);
const operatingMode = value === "off" ? "off" : YUBA_SYSTEM_MODE_TO_OPERATING_MODE[value];
if (operatingMode === undefined)
throw new Error(`Unsupported system_mode: ${value}`);
await setOperatingMode(entity, operatingMode);
return { state: { system_mode: value } };
},
convertGet: readYubaPackedState,
};
const fanMode = {
key: ["fan_mode"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value, key);
if (!(value in YUBA_FAN_LOOKUP))
throw new Error(`Unsupported fan_mode: ${value}`);
const fan = value;
const swing = await getYubaSwingRaw(entity, meta);
const fanControl = (YUBA_FAN_LOOKUP[fan] << 4) | swing;
const packed = 0xffffffffff000000n | (BigInt(fanControl) << 16n) | BigInt(YUBA_FAN_STATE_LOOKUP[fan]);
await writeYubaPackedState(entity, packed);
return { state: { fan_mode: value } };
},
convertGet: readYubaPackedState,
};
const swingMode = {
key: ["swing_mode"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value, key);
if (value !== "on" && value !== "off")
throw new Error(`Unsupported swing_mode: ${value}`);
const fan = await getYubaFanMode(entity, meta);
const swing = value === "on" ? 0x0c : 0x0d;
const fanControl = (YUBA_FAN_LOOKUP[fan] << 4) | swing;
const packed = 0xffffffffff000000n | (BigInt(fanControl) << 16n) | BigInt(YUBA_FAN_STATE_LOOKUP[fan]);
await writeYubaPackedState(entity, packed);
return { state: { swing_mode: value } };
},
convertGet: readYubaPackedState,
};
const heaterPower = {
key: ["heater_power"],
convertGet: readYubaPackedState,
};
const nightLightMode = {
key: ["night_light_mode"],
convertSet: async (entity, key, value) => {
const enabled = parseYubaEnabled(value, key);
const current = Number(await readYubaAttributeValue(entity, YUBA_ATTR_NIGHT_LIGHT));
const next = enabled ? current & ~1 : current | 1;
await writeYubaAttribute(entity, YUBA_ATTR_NIGHT_LIGHT, next, zigbee_herdsman_1.Zcl.DataType.UINT32);
return { state: { night_light_mode: enabled ? "ON" : "OFF" } };
},
convertGet: async (entity) => {
await readYubaAttributes(entity, [YUBA_ATTR_NIGHT_LIGHT]);
},
};
const mutePrompt = {
key: ["mute_prompt_tone", "mute_prompt_start_time", "mute_prompt_end_time"],
convertSet: async (entity, key, value) => {
if (key === "mute_prompt_tone") {
const enabled = parseYubaEnabled(value, key);
await writeYubaAttribute(entity, YUBA_ATTR_MUTE_PROMPT_TONE, enabled ? 1 : 0, zigbee_herdsman_1.Zcl.DataType.UINT8);
return { state: { mute_prompt_tone: enabled ? "ON" : "OFF" } };
}
const current = Number(await readYubaAttributeValue(entity, YUBA_ATTR_MUTE_PROMPT_TIME)) >>> 0;
const minutes = parseYubaClockTime(value, key);
const schedule = key === "mute_prompt_start_time" ? (current & 0xffff0000) | minutes : (minutes << 16) | (current & 0xffff);
await writeYubaAttribute(entity, YUBA_ATTR_MUTE_PROMPT_TIME, schedule >>> 0, zigbee_herdsman_1.Zcl.DataType.UINT32);
return { state: { [key]: formatYubaClockTime(minutes) } };
},
convertGet: async (entity, key) => {
await readYubaAttributes(entity, [key === "mute_prompt_tone" ? YUBA_ATTR_MUTE_PROMPT_TONE : YUBA_ATTR_MUTE_PROMPT_TIME]);
},
};
const constantTemperatureMode = {
key: ["constant_temperature_mode"],
convertSet: async (entity, key, value) => {
const enabled = parseYubaEnabled(value, key);
await writeYubaAttribute(entity, YUBA_ATTR_CONSTANT_TEMPERATURE_MODE, enabled ? 1 : 0, zigbee_herdsman_1.Zcl.DataType.UINT8);
return { state: { constant_temperature_mode: enabled ? "ON" : "OFF" } };
},
convertGet: async (entity) => {
await readYubaAttributes(entity, [YUBA_ATTR_CONSTANT_TEMPERATURE_MODE]);
},
};
const currentTemperature = {
key: ["local_temperature"],
convertGet: readYubaPackedState,
};
return {
fromZigbee: [fromYuba],
toZigbee: [
targetTemperature,
operatingMode,
systemMode,
fanMode,
swingMode,
heaterPower,
nightLightMode,
mutePrompt,
constantTemperatureMode,
currentTemperature,
],
exposes: [
e
.climate()
.withLocalTemperature(ea.STATE_GET)
.withSetpoint("current_heating_setpoint", 16, 45, 1, ea.ALL)
.withSystemMode(["off", "heat", "dry", "fan_only"], ea.ALL)
.withRunningState(["idle", "heat", "fan_only"], ea.STATE)
.withFanMode(["low", "medium", "high"], ea.ALL)
.withSwingMode(["off", "on"], ea.ALL)
.withDescription("Aqara bathroom heater climate controls"),
e.binary("heater_power", ea.STATE_GET, true, false).withDescription("Bathroom heater power"),
e.enum("operating_mode", ea.ALL, ["off", ...Object.keys(YUBA_MODE_LOOKUP)]).withDescription("Bathroom heater operating mode"),
e.binary("night_light_mode", ea.ALL, "ON", "OFF").withDescription("Enable scheduled night-light mode").withCategory("config"),
e.binary("mute_prompt_tone", ea.ALL, "ON", "OFF").withDescription("Mute device operation prompt tones").withCategory("config"),
e.text("mute_prompt_start_time", ea.ALL).withDescription("Prompt-tone mute start time in HH:MM format").withCategory("config"),
e.text("mute_prompt_end_time", ea.ALL).withDescription("Prompt-tone mute end time in HH:MM format").withCategory("config"),
e
.binary("constant_temperature_mode", ea.ALL, "ON", "OFF")
.withDescription("Automatically regulate warm-air speed at the target temperature")
.withCategory("config"),
],
configure: [
async (device) => {
const endpoint = device.getEndpoint(1);
await safeYubaRead(endpoint, YUBA_LUMI_CLUSTER, [
YUBA_ATTR_PACKED_STATE,
YUBA_ATTR_MUTE_PROMPT_TONE,
YUBA_ATTR_MUTE_PROMPT_TIME,
YUBA_ATTR_CONSTANT_TEMPERATURE_MODE,
YUBA_ATTR_NIGHT_LIGHT,
], { manufacturerCode: exports.manufacturerCode });
},
],
isModernExtend: true,
};
}
const W600_NS = "zhc:aqara_w600";
const W600_LUMI_CLUSTER = "manuSpecificLumi";
const W600_THERMOSTAT_CLUSTER = "hvacThermostat";
const W600_ATTR_TEMP_SETPOINT_HOLD_DURATION = 0x0024;
const W600_ATTR_SYSTEM_MODE = 0x0271;
const W600_ATTR_SCHEDULE = 0x027d;
const W600_ATTR_PRESET = 0x0311;
const W600_ATTR_PRESET_TEMPERATURE_TABLE = 0x0317;
const W600_ATTR_SENSOR_SOURCE = 0x0280;
const W600_ATTR_SENSOR_BINDING = 0xfff2;
const W600_ATTR_HEARTBEAT = 0x00f7;
const W600_ATTR_VALVE_POSITION = 0x0360;
const W600_EXTERNAL_TEMP_SENSOR = node_buffer_1.Buffer.from("00158d00019d1b98", "hex");
const W600_PRESET_TABLE_STORE_KEY = "w600PresetTemperatureTable";
const W600_SENSOR_BINDING_COUNTER_STORE_KEY = "w600SensorBindingCounter";
const W600_MANUAL_CUSTOM_PRESET_SUPPRESSION_UNTIL_STORE_KEY = "w600ManualCustomPresetSuppressionUntil";
const W600_WEEKLY_SCHEDULE_DRAFT_STORE_KEY = "w600WeeklyScheduleDraft";
const W600_WEEKLY_SCHEDULE_OTA_STAGE_STORE_KEY = "w600WeeklyScheduleOtaStage";
const W600_WEEKLY_SCHEDULE_UPLOAD_STATE_STORE_KEY = "w600WeeklyScheduleUploadState";
const W600_MANUAL_CUSTOM_PRESET_SUPPRESSION_MS = 15_000;
const W600_WEEKLY_SCHEDULE_OTA_STAGE_TTL_MS = 5 * 60 * 1000;
const W600_WEEKLY_SCHEDULE_UPLOAD_TIMEOUTS = new Map();
const W600_SENSOR_BINDING_MARKER = node_buffer_1.Buffer.from([0x00, 0x01, 0x00, 0x55]);
const W600_EXTERNAL_TEMP_SENSOR_DESCRIPTOR = node_buffer_1.Buffer.from([
0x15, 0x0a, 0x01, 0x00, 0x00, 0x01, 0x06, 0xe6, 0xb8, 0xa9, 0xe5, 0xba, 0xa6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x07, 0x65,
]);
const W600_PRESET_ORDER = ["home", "away", "sleep", "vacation", "wind_down"];
const W600_PRESET_BY_ID = {
1: "home",
2: "away",
3: "sleep",
5: "vacation",
6: "wind_down",
255: "none",
};
const W600_PRESET_ID_BY_NAME = {
home: 1,
away: 2,
sleep: 3,
vacation: 5,
wind_down: 6,
};
const W600_PRESET_TEMPERATURE_DEFINITIONS = [
{ preset: "home", property: "preset_home_temperature", label: "Home temperature", description: "Home preset temperature" },
{ preset: "away", property: "preset_away_temperature", label: "Away temperature", description: "Away preset temperature" },
{ preset: "sleep", property: "preset_sleep_temperature", label: "Sleep temperature", description: "Sleep preset temperature" },
{ preset: "vacation", property: "preset_vacation_temperature", label: "Vacation temperature", description: "Vacation preset temperature" },
{ preset: "wind_down", property: "preset_wind_down_temperature", label: "Wind down temperature", description: "Wind-down preset temperature" },
];
const W600_PRESET_NAME_BY_PROPERTY = Object.fromEntries(W600_PRESET_TEMPERATURE_DEFINITIONS.map((definition) => [definition.property, definition.preset]));
const W600_PROPERTY_BY_PRESET_NAME = Object.fromEntries(W600_PRESET_TEMPERATURE_DEFINITIONS.map((definition) => [definition.preset, definition.property]));
const W600_WEEKLY_SCHEDULE_DAY_DEFINITIONS = [
{ label: "Sunday", mask: 0x01, property: "weekly_schedule_sunday" },
{ label: "Monday", mask: 0x02, property: "weekly_schedule_monday" },
{ label: "Tuesday", mask: 0x04, property: "weekly_schedule_tuesday" },
{ label: "Wednesday", mask: 0x08, property: "weekly_schedule_wednesday" },
{ label: "Thursday", mask: 0x10, property: "weekly_schedule_thursday" },
{ label: "Friday", mask: 0x20, property: "weekly_schedule_friday" },
{ label: "Saturday", mask: 0x40, property: "weekly_schedule_saturday" },
];
const W600_WEEKLY_SCHEDULE_DAY_PROPERTIES = W600_WEEKLY_SCHEDULE_DAY_DEFINITIONS.map(({ property }) => property);
const W600_WEEKLY_SCHEDULE_HEADER_STRING = "ROUTERX-ENCRYPTEDO00";
const W600_WEEKLY_SCHEDULE_IMAGE_TYPE = 0x1400;
const W600_WEEKLY_SCHEDULE_FILE_VERSION = 0x00000100;
const W600_WEEKLY_SCHEDULE_STACK_VERSION = 0x0002;
const W600_WEEKLY_SCHEDULE_IMAGE_NOTIFY_QUERY_JITTER = 48;
const W600_WEEKLY_SCHEDULE_UPLOAD_STATUSES = ["idle", "staged", "in_progress", "success", "failed"];
function findW600ClimateExpose(extend) {
return extend.exposes?.find((expose) => typeof expose !== "function" && expose.type === "climate");
}
function normalizeW600EnumKey(value) {
return typeof value === "string"
? value
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_")
: undefined;
}
function parseW600EnumName(value, lookup, key) {
const normalized = normalizeW600EnumKey(value);
if (normalized != null && Object.hasOwn(lookup, normalized)) {
return normalized;
}
throw new Error(`${key} must be one of: ${Object.keys(lookup).join(", ")}`);
}
function parseW600HalfDegreeTemperature(value, key, min, max) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
throw new Error(`${key} must be a number`);
}
if (numeric < min || numeric > max) {
throw new Error(`${key} must be between ${min} and ${max}`);
}
const scaled = Math.round(numeric * 100);
if (scaled % 50 !== 0) {
throw new Error(`${key} must use 0.5 C steps`);
}
return scaled;
}
function getW600DeviceStoreKey(deviceOrEntity) {
if (typeof deviceOrEntity === "string") {
return deviceOrEntity;
}
if ("ieeeAddr" in deviceOrEntity && typeof deviceOrEntity.ieeeAddr === "string") {
return deviceOrEntity.ieeeAddr;
}
if ("deviceIeeeAddress" in deviceOrEntity && typeof deviceOrEntity.deviceIeeeAddress === "string") {
return deviceOrEntity.deviceIeeeAddress;
}
throw new Error("Unable to derive device store key");
}
function getW600DeviceBuffer(entity) {
return node_buffer_1.Buffer.from(entity.deviceIeeeAddress.substring(2), "hex");
}
function parseW600SensorSelection(value, key) {
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "internal" || normalized === "external") {
return normalized;
}
}
throw new Error(`${key} must be one of: internal, external`);
}
function getW600SensorSelectionFromState(value) {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
return normalized === "internal" || normalized === "external" ? normalized : undefined;
}
function parseW600ExternalTemperatureInput(value, key) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
throw new Error(`${key} must be a number`);
}
if (numeric < -40 || numeric > 125) {
throw new Error(`${key} must be between -40 and 125`);
}
return Math.round(numeric * 100);
}
function decodeW600Heartbeat(buffer) {
const heartbeat = {};
let offset = 0;
while (offset + 2 <= buffer.length) {
const key = buffer.readUInt8(offset);
const type = buffer.readUInt8(offset + 1);
offset += 2;
switch (type) {
case zigbee_herdsman_1.Zcl.DataType.BOOLEAN:
case zigbee_herdsman_1.Zcl.DataType.UINT8:
case zigbee_herdsman_1.Zcl.DataType.ENUM8:
if (offset + 1 > buffer.length)
return heartbeat;
heartbeat[key] = buffer.readUInt8(offset);
offset += 1;
break;
case zigbee_herdsman_1.Zcl.DataType.INT8:
if (offset + 1 > buffer.length)
return heartbeat;
heartbeat[key] = buffer.readInt8(offset);
offset += 1;
break;
case zigbee_herdsman_1.Zcl.DataType.UINT16:
case zigbee_herdsman_1.Zcl.DataType.ENUM16:
if (offset + 2 > buffer.length)
return heartbeat;
heartbeat[key] = buffer.readUInt16LE(offset);
offset += 2;
break;
case zigbee_herdsman_1.Zcl.DataType.INT16:
if (offset + 2 > buffer.length)
return heartbeat;
heartbeat[key] = buffer.readInt16LE(offset);
offset += 2;
break;
case zigbee_herdsman_1.Zcl.DataType.UINT32:
if (offset + 4 > buffer.length)
return heartbeat;
heartbeat[key] = buffer.readUInt32LE(offset);
offset += 4;
break;
case zigbee_herdsman_1.Zcl.DataType.OCTET_STR: {
if (offset + 1 > buffer.length)
return heartbeat;
const length = buffer.readUInt8(offset);
offset += 1;
if (offset + length > buffer.length)
return heartbeat;
heartbeat[key] = buffer.subarray(offset, offset + length);
offset += length;
break;
}
default:
logger_1.logger.debug(`Unsupported W600 heartbeat type 0x${type.toString(16)} for sub-key 0x${key.toString(16)}`, W600_NS);
return heartbeat;
}
}
return heartbeat;
}
function decodeW600Heartbeat9c(buffer) {
if (buffer.length < 8) {
return undefined;
}
const windowOpenStatus = buffer[4];
const windowOpen = windowOpenStatus === 0x00 ? false : windowOpenStatus === 0x0d || windowOpenStatus === 0x0e ? true : undefined;
const valveAlarm = windowOpenStatus === 0x10 ? true : windowOpenStatus === 0x00 || windowOpenStatus === 0x0d || windowOpenStatus === 0x0e ? false : undefined;
return {
valveAlarm,
windowOpen,
};
}
function parseW600BinaryEnabled(value) {
if (value === 1 || value === true) {
return true;
}
if (value === 0 || value === false) {
return false;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "on" || normalized === "true") {
return true;
}
if (normalized === "off" || normalized === "false") {
return false;
}
}
return undefined;
}
function parseRequiredW600BinaryEnabled(value, key) {
const enabled = parseW600BinaryEnabled(value);
if (enabled == null) {
throw new Error(`${key} must be one of: ON, OFF`);
}
return enabled;
}
function parseW600ScheduleEnabled(value) {
return parseW600BinaryEnabled(value);
}
function parseW600TemperatureSetpointHold(value) {
if (typeof value === "boolean") {
return value;
}
if (value === 1 || value === "true") {
return true;
}
if (value === 0 || value === "false") {
return false;
}
return undefined;
}
function getW600OverrideActiveState(state) {
return parseW600TemperatureSetpointHold(state?.override_active) ?? parseW600TemperatureSetpointHold(state?.temperature_setpoint_hold);
}
function parseW600HeatingEnabled(value) {
if (value === "off") {
return false;
}
if (value === "heat" || value === "auto") {
return true;
}
return undefined;
}
function getRequestedW600ScheduleEnabled(meta) {
if (meta.message?.schedule != null) {
return parseRequiredW600BinaryEnabled(meta.message.schedule, "schedule");
}
const requestedSystemMode = normalizeW600EnumKey(meta.message?.system_mode);
if (requestedSystemMode === "auto") {
return true;
}
if (requestedSystemMode === "heat" || requestedSystemMode === "off") {
return false;
}
const scheduleEnabled = parseW600ScheduleEnabled(meta.state?.schedule);
if (scheduleEnabled != null) {
return scheduleEnabled;
}
if (meta.state?.system_mode === "auto") {
return true;
}
if (meta.state?.system_mode === "heat" || meta.state?.system_mode === "off") {
return false;
}
return undefined;
}
async function safeW600Read(endpoint, cluster, attributes, options) {
try {
await endpoint.read(cluster, attributes, options);
}
catch (error) {
const details = error instanceof Error ? error.message : String(error);
logger_1.logger.debug(`Safe read failed for ${endpoint.deviceIeeeAddress} on ${String(cluster)} [${attributes.join(", ")}]: ${details}`, W600_NS);
}
}
function readW600LumiAttribute(entity, attribute) {
return entity.read(W600_LUMI_CLUSTER, [attribute], { manufacturerCode: exports.manufacturerCode });
}
function writeW600LumiAttribute(entity, attribute, value, type = zigbee_herdsman_1.Zcl.DataType.UINT8) {
return entity.write(W600_LUMI_CLUSTER, {
[attribute]: { value, type },
}, { manufacturerCode: exports.manufacturerCode });
}
function getNextW600SensorBindingCounter(entity) {
const device = "ieeeAddr" in entity ? entity : entity.getDevice();
const storedCounter = device.meta?.[W600_SENSOR_BINDING_COUNTER_STORE_KEY];
const counter = typeof storedCounter === "number" && Number.isInteger(storedCounter) && storedCounter >= 0 && storedCounter <= 0xff ? storedCounter : 0x12;
device.meta ??= {};
device.meta[W600_SENSOR_BINDING_COUNTER_STORE_KEY] = (counter + 1) & 0xff;
device.save();
return counter;
}
function buildW600SensorPayload(entity, action, payload) {
const header = node_buffer_1.Buffer.from([0xaa, 0x71, payload.length + 3, 0x44, getNextW600SensorBindingCounter(entity)]);
const checksum = (0x200 - header.reduce((sum, byte) => sum + byte, 0)) & 0xff;
return node_buffer_1.Buffer.concat([header, node_buffer_1.Buffer.from([checksum, action, zigbee_herdsman_1.Zcl.DataType.OCTET_STR, payload.length]), payload]);
}
function getW600TimestampBuffer() {
const timestamp = node_buffer_1.Buffer.alloc(4);
timestamp.writeUInt32BE(Math.floor(Date.now() / 1000), 0);
return timestamp;
}
function buildW600ExternalTempSensorBindPayload(entity) {
const payload = node_buffer_1.Buffer.concat([
getW600TimestampBuffer(),
node_buffer_1.Buffer.from([0x14]),
getW600DeviceBuffer(entity),
W600_EXTERNAL_TEMP_SENSOR,
W600_SENSOR_BINDING_MARKER,
W600_EXTERNAL_TEMP_SENSOR_DESCRIPTOR,
]);
return buildW600SensorPayload(entity, 0x02, payload);
}
function buildW600ExternalTempSensorUnbindPayload(entity) {
const payload = node_buffer_1.Buffer.concat([getW600TimestampBuffer(), node_buffer_1.Buffer.from([0x14]), getW600DeviceBuffer(entity), node_buffer_1.Buffer.alloc(12)]);
return buildW600SensorPayload(entity, 0x04, payload);
}
function buildW600ExternalTemperaturePayload(entity, centiDegrees) {
const temperatureBuffer = node_buffer_1.Buffer.alloc(4);
temperatureBuffer.writeFloatBE(centiDegrees, 0);
return buildW600SensorPayload(entity, 0x05, node_buffer_1.Buffer.concat([W600_EXTERNAL_TEMP_SENSOR, W600_SENSOR_BINDING_MARKER, temperatureBuffer]));
}
function createW600Heartbeat() {
return {
exposes: [
e.battery().withDescription("Battery percentage"),
e.valve_alarm().withDescription("Indicates whether temperature control abnormal notification has reported an active alert"),
e.binary("window_open", ea.STATE, true, false).withDescription("Indicates whether open window detection has reported an open window"),
],
fromZigbee: [
{
cluster: W600_LUMI_CLUSTER,
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const value = msg.data[W600_ATTR_HEARTBEAT];
if (!node_buffer_1.Buffer.isBuffer(value)) {
return;
}
const heartbeat = decodeW600Heartbeat(value);
const result = {};
if (typeof heartbeat[0x0d] === "number" && Number.isFinite(heartbeat[0x0d])) {
const device = meta.device ?? msg.device;
device.softwareBuildID = exports.trv.decodeFirmwareVersionString(heartbeat[0x0d]);
}
if (typeof heartbeat[0x18] === "number" && Number.isFinite(heartbeat[0x18])) {
result.battery = Math.max(0, Math.min(100, heartbeat[0x18]));
}
if (node_buffer_1.Buffer.isBuffer(heartbeat[0x9c])) {
const heartbeat9c = decodeW600Heartbeat9c(heartbeat[0x9c]);
if (heartbeat9c) {
if (typeof heartbeat9c.valveAlarm === "boolean") {
result.valve_alarm = heartbeat9c.valveAlarm;
}
if (typeof heartbeat9c.windowOpen === "boolean") {
result.window_open = heartbeat9c.windowOpen;
}
}
}
return Object.keys(result).length > 0 ? result : undefined;
},
},
],
isModernExtend: true,
};
}
function deriveW600SystemMode(args) {
if (args.heatingEnabled === false) {
return "off";
}
if (args.heatingEnabled === true && args.scheduleEnabled === true) {
return "auto";
}
if (args.heatingEnabled === true && args.scheduleEnabled === false) {
return "heat";
}
return undefined;
}
function deriveW600RunningStateFromValvePosition(position) {
if (typeof position !== "number" || !Number.isFinite(position)) {
return undefined;
}
if (position > 0) {
return "heat";
}
if (position === 0) {
return "idle";
}
return undefined;
}
function buildW600ScheduleState(enabled) {
return { schedule: enabled ? "ON" : "OFF" };
}
function isW600ManualCustomPresetSuppressionActive(deviceOrEntity) {
const suppressUntil = globalStore.getValue(getW600DeviceStoreKey(deviceOrEntity), W600_MANUAL_CUSTOM_PRESET_SUPPRESSION_UNTIL_STORE_KEY, 0);
return typeof suppressUntil === "number" && suppressUntil > Date.now();
}
function startW600ManualCustomPresetSuppression(deviceOrEntity, durationMs = W600_MANUAL_CUSTOM_PRESET_SUPPRESSION_MS) {
globalStore.putValue(getW600DeviceStoreKey(deviceOrEntity), W600_MANUAL_CUSTOM_PRESET_SUPPRESSION_UNTIL_STORE_KEY, Date.now() + durationMs);
}
function clearW600ManualCustomPresetSuppression(deviceOrEntity) {
globalStore.putValue(getW600DeviceStoreKey(deviceOrEntity), W600_MANUAL_CUSTOM_PRESET_SUPPRESSION_UNTIL_STORE_KEY, 0);
}
function getCachedW600PresetTemperatureTable(entity, meta) {
const storeKey = getW600DeviceStoreKey(entity);
const cached = globalStore.getValue(storeKey, W600_PRESET_TABLE_STORE_KEY);
if (cached && typeof cached === "object") {
return { ...cached };
}
const table = {};
for (const { preset, property } of W600_PRESET_TEMPERATURE_DEFINITIONS) {
const stateValue = meta.state?.[property];
if (typeof stateValue !== "number" || !Number.isFinite(stateValue)) {
return undefined;
}
table[preset] = Math.round(stateValue * 100);
}
return table;
}
function decodeW600PresetTemperatureTable(buffer) {
if (buffer.length < 1) {
return undefined;
}
const entryCount = buffer.readUInt8(0);
const table = {};
for (let index = 0; index < entryCount; index++) {
const offset = 1 + index * 5;
if (offset + 5 > buffer.length) {
break;
}
const presetId = buffer.readUInt8(offset);
const presetName = W600_PRESET_BY_ID[presetId];
if (!presetName || presetName === "none") {
continue;
}
table[presetName] = buffer.readUInt16LE(offset + 3);
}
return Object.keys(table).length === 0 ? undefined : table;
}
function encodeW600PresetTemperatureTable(table) {
const buffer = node_buffer_1.Buffer.alloc(1 + W600_PRESET_ORDER.length * 5);
buffer.writeUInt8(W600_PRESET_ORDER.length, 0);
W600_PRESET_ORDER.forEach((presetName, index) => {
const centiDegrees = table[presetName];
if (!Number.isInteger(centiDegrees)) {
throw new Error(`Missing cached value for ${presetName} preset temperature`);
}
const offset = 1 + index * 5;
buffer.writeUInt8(W600_PRESET_ID_BY_NAME[presetName], offset);
buffer.writeUInt8(0, offset + 1);
buffer.writeUInt8(0, offset + 2);
buffer.writeUInt16LE(centiDegrees, offset + 3);
});
return buffer;
}
function parseW600ScheduleTriggerValue(value, key) {
if (value === true || value === 1) {
return;
}
if (typeof value === "string") {
const normalized = normalizeW600EnumKey(value);
if (normalized && ["trigger", "press", "pressed", "start", "save", "clear", "true", "1"].includes(normalized)) {
return;
}
}
throw new Error(`${key} must be one of: trigger, press, start`);
}
function formatW600ScheduleTime(totalMinutes) {
const hours = Math.floor(totalMinutes / 60)
.toString()
.padStart(2, "0");
const minutes = (totalMinutes % 60).toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
function formatW600ScheduleDayTransitions(transitions) {
return transitions.map(({ minutes, preset }) => `${formatW600ScheduleTime(minutes)}/${preset}`).join(", ");
}
function parseW600ScheduleDayTransitions(value, key) {
if (typeof value !== "string") {
throw new Error(`${key} must be a string`);
}
const compact = value.replace(/\s+/g, "");
if (compact === "") {
return [];
}
const parts = compact.split(",");
if (parts.some((part) => part.length === 0)) {
throw new Error(`${key} must use comma-delimited entries in the format HH:MM/preset`);
}
const transitions = [];
const seenMinutes = new Set();
for (const part of parts) {
const match = part.match(/^([0-9]|[01]\d|2[0-3]):([0-5]\d)\/(.+)$/);
if (!match) {
throw new Error(`${key} entries must use the format H:MM/preset or HH:MM/preset, for example 08:00/home`);
}
const minutes = Number.parseInt(match[1], 10) * 60 + Number.parseInt(match[2], 10);
if (seenMinutes.has(minutes)) {
throw new Error(`${key} cannot contain multiple entries for ${formatW600ScheduleTime(minutes)}`);
}
seenMinutes.add(minutes);
transitions.push({
minutes,
preset: parseW600EnumName(match[3], W600_PRESET_ID_BY_NAME, key),
});
}
transitions.sort((left, right) => left.minutes - right.minutes);
return transitions;
}
function createEmptyW600WeeklyScheduleDraft() {
return Object.fromEntries(W600_WEEKLY_SCHEDULE_DAY_DEFINITIONS.map(({ property }) => [property, ""]));
}
function buildW600WeeklyScheduleStatePayload(draft) {
return Object.fromEntries(W600_WEEKLY_SCHEDULE_DAY_DEFINITIONS.map(({ property }) => [property, draft[property] === "" ? null : draft[property]]));
}
function buildW600WeeklyScheduleUploadStatePayload(uploadState) {
return {
schedule_upload_status: uploadState.status,
};
}
function normalizeW600WeeklyScheduleDraft(draft) {
const normalizedDraft = createEmptyW600WeeklyScheduleDraft();
const records = [];
for (const { mask, property } of W600_WEEKLY_SCHEDULE_DAY_DEFINITIONS) {
const transitions = parseW600ScheduleDayTransitions(draft[property] ?? "", property);
normalizedDraft[property] = formatW600ScheduleDayTransitions(transitions);
for (const transition of transitions) {
records.push({ dayMask: mask, minutes: transition.minutes, presetId: W600_PRESET_ID_BY_NAME[transition.preset] });
}
}
records.sort((left, right) => left.dayMask - right.dayMask || left.minutes - right.minutes || left.presetId - right.presetId);
return { draft: normalizedDraft, records };
}
function encodeW600WeeklyScheduleSch2(records) {
if (records.length > 0xff) {
throw new Error("Weekly schedule contains too many entries");
}
const buffer = node_buffer_1.Buffer.alloc(5 + records.length * 12);
buffer.write("SCH2", 0, "ascii");
buffer.writeUInt8(records.length, 4);
records.forEach((record, index) => {
const offset = 5 + index * 12;
buffer.writeUInt8(record.dayMask, offset);
buffer.writeUInt16LE(record.minutes, offset + 1);
buffer.writeUInt8(0x01, offset + 3);
buffer.writeUInt8(record.presetId, offset + 4);
});
return buffer;
}
function buildW600WeeklyScheduleCrc32Table() {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index++) {
let value = index;
for (let bit = 0; bit < 8; bit++) {
value = (value & 1) === 1 ? (value >>> 1) ^ 0xedb88320 : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
}
const W600_WEEKLY_SCHEDULE_CRC32_TABLE = buildW600WeeklyScheduleCrc32Table();
function computeW600WeeklyScheduleCrc32(buffer) {
let crc = 0xffffffff;
for (const value of buffer) {
crc = W600_WEEKLY_SCHEDULE_CRC32_TABLE[(crc ^ value) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function buildW600WeeklyScheduleSubelement(sch2Payload) {
const subelement = node_buffer_1.Buffer.alloc(35 + sch2Payload.length);
subelement.writeUInt32LE(0x014f, 0);
subelement.writeUInt32LE(sch2Payload.length + 21, 4);
subelement.writeUInt8(0x01, 22);
subelement.writeUInt8(0x04, 23);
subelement.writeUInt8(0x01, 24);
subelement.writeUInt8(0x04, 34);
sch2Payload.copy(subelement, 35);
subelement.writeUInt32LE(computeW600WeeklyScheduleCrc32(subelement), 10);
return subelement;
}
function buildW600WeeklyScheduleImage(records) {
const sch2Payload = encodeW600WeeklyScheduleSch2(records);
const subelement = buildW600WeeklyScheduleSubelement(sch2Payload);
const header = node_buffer_1.Buffer.alloc(56);
const headerString = node_buffer_1.Buffer.alloc(32);
headerString.write(W600_WEEKLY_SCHEDULE_HEADER_STRING, 0, "ascii");
header.writeUInt32LE(0x0beef11e, 0);
header.writeUInt16LE(0x0100, 4);
header.writeUInt16LE(56, 6);
header.writeUInt16LE(0, 8);
header.writeUInt16LE(exports.manufacturerCode, 10);
header.writeUInt16LE(W600_WEEKLY_SCHEDULE_IMAGE_TYPE, 12);
header.writeUInt32LE(W600_WEEKLY_SCHEDULE_FILE_VERSION, 14);
header.writeUInt16LE(W600_WEEKLY_SCHEDULE_STACK_VERSION, 18);
headerString.copy(header, 20);
const subelementHeader = node_buffer_1.Buffer.alloc(6);
subelementHeader.writeUInt16LE(0xf006, 0);
subelementHeader.writeUInt32LE(subelement.length, 2);
const image = node_buffer_1.Buffer.concat([header, subelementHeader, subelement]);
image.writeUInt32LE(image.length, 52);
return image;
}
function normalizeW600WeeklyScheduleUploadState(uploadState) {
const normalizedStatus = W600_WEEKLY_SCHEDULE_UPLOAD_STATUSES.includes(uploadState?.status)
? uploadState?.status
: "idle";
return {
status: normalizedStatus,
error: typeof uploadState?.error === "string" ? uploadState.error : "",
operation: uploadState?.operation === "clear_schedule" ? "clear_schedule" : "save_schedule",
recordCount: Number.isInteger(uploadState?.recordCount) && (uploadState?.recordCount ?? 0) >= 0 ? uploadState?.recordCount : 0,
uploadId: typeof uploadState?.uploadId === "string" ? uploadState.uploadId : undefined,
updatedAt: typeof uploadState?.updatedAt === "number" ? uploadState.updatedAt : 0,
};
}
function getW600WeeklyScheduleUploadStatePayload(deviceOrEntity) {
const uploadState = normalizeW600WeeklyScheduleUploadState(globalStore.getValue(getW600DeviceStoreKey(deviceOrEntity), W600_WEEKLY_SCHEDULE_UPLOAD_STATE_STORE_KEY));
return buildW600WeeklyScheduleUploadStatePayload(uploadState);
}
function updateW600WeeklyScheduleUploadState(deviceOrEntity, partialState, publish) {
const storeKey = getW600DeviceStoreKey(deviceOrEntity);
const currentState = normalizeW600WeeklyScheduleUploadState(globalStore.getValue(storeKey, W600_WEEKLY_SCHEDULE_UPLOAD_STATE_STORE_KEY));
const nextState = normalizeW600WeeklyScheduleUploadState({
...currentState,
...partialState,
updatedAt: Date.now(),
});
globalStore.putValue(storeKey, W600_WEEKLY_SCHEDULE_UPLOAD_STATE_STORE_KEY, nextState);
const payload = buildW600WeeklyScheduleUploadStatePayload(nextState);
if (typeof publish === "function") {
publish(payload);
}
return { state: payload, uploadState: nextState };
}
function clearW600WeeklyScheduleUploadTimeout(deviceOrEntity) {
const storeKey = getW600DeviceStoreKey(deviceOrEntity);
const timeout = W600_WEEKLY_SCHEDULE_UPLOAD_TIMEOUTS.get(storeKey);
if (timeout != null) {
clearTimeout(timeout);
W600_WEEKLY_SCHEDULE_UPLOAD_TIMEOUTS.delete(storeKey);
}
}
function describeW600WeeklyScheduleOperation(operation) {
return operation === "clear_schedule" ? "clear schedule upload" : "save schedule upload";
}
function failW600WeeklyScheduleUpload(deviceOrEntity, error, publish) {
const storeKey = getW600DeviceStoreKey(deviceOrEntity);
const uploadState = normalizeW600WeeklyScheduleUploadState(globalStore.getValue(storeKey, W600_WEEKLY_SCHEDULE_UPLOAD_STATE_STORE_KEY));
const message = typeof error === "string" && error.trim() !== "" ? error : "Unknown weekly schedule upload failure";
clearW600WeeklyScheduleUploadTimeout(storeKey);
globalStore.clearValue(storeKey, W600_WEEKLY_SCHEDULE_OTA_STAGE_STORE_KEY);
logger_1.logger.warning(`W600 ${describeW600WeeklyScheduleOperation(uploadState.operation)} failed for ${storeKey}: ${message}`, W600_NS);
return updateW600WeeklyScheduleUploadState(storeKey, { status: "failed", error: message }, publish);
}
function armW600WeeklyScheduleUploadTimeout(deviceOrEntity, uploadId, publish) {
const storeKey = getW600DeviceStoreKey(deviceOrEntity);
clearW600WeeklyScheduleUploadTimeout(storeKey);
const timeout = setTimeout(() => {
const stage = globalStore.getValue(storeKey, W600_WEEKLY_SCHEDULE_OTA_STAGE_STORE_KEY);
if (!stage || stage.uploadId !== uploadId) {
return;
}
failW600WeeklyScheduleUpload(storeKey, "Timed out waiting for the device to finish the weekly schedule OTA transfer", publish);
}, W600_WEEKLY_SCHEDULE_OTA_STAGE_TTL_MS).unref();
W600_WEEKLY_SCHEDULE_UPLOAD_TIMEOUTS.set(storeKey, timeout);
}
function updateW600WeeklyScheduleOtaStage(deviceOrEntity, partialStage) {
const storeKey = getW600DeviceStoreKey(deviceOrEntity);
const stage = globalStore.getValue(storeKey, W600_WEEKLY_SCHEDULE_OTA_STAGE_STORE_KEY);
if (!stage || !node_buffer_1.Buffer.isBuffer(stage.image)) {
return undefined;
}
const nextStage = {
...stage,
...partialStage,
lastActivityAt: Date.now(),
};
globalStore.putValue(storeKey, W600_WEEKLY_SCHEDULE_OTA_STAGE_STORE_KEY, nextStage);
return nextStage;
}
function markW600WeeklyScheduleUploadStarted(deviceOrEntity, publish) {
const storeKey = getW600DeviceStoreKey(deviceOrEntity);
const currentState = normalizeW600WeeklyScheduleUploadState(globalStore.getValue(storeKey, W600_WEEKLY_SCHEDULE_UPLOAD_STATE_STORE_KEY));
const stage = updateW600WeeklyScheduleOtaStage(storeKey, {});
if (!stage) {
return undefined;
}
const shouldPublish = currentState.status !== "in_progress" || currentState.uploadId !== stage.uploadId || currentState.error !== "";
if (shouldPublish) {
logger_1.logger.info(`W600 ${describeW600WeeklyScheduleOperation(stage.operation)} started for ${storeKey}; image size ${stage.image.length} bytes`, W600_NS);
}
armW600WeeklyScheduleUploadTimeout(storeKey, stage.uploadId, publish);
return updateW600WeeklyScheduleUploadState(storeKey, {
status: "in_progress",
error: "",
operation: stage.operation,
recordCount: stage.recordCount,
uploadId: stage.uploadId,
}, shouldPublish ? publish : undefined);
}
function noteW600WeeklyScheduleUploadBlock(deviceOrEntity, publish) {
const stage = updateW600WeeklyScheduleOtaStage(deviceOrEntity, {});
if (!stage) {
return undefined;
}
armW600WeeklyScheduleUploadTimeout(deviceOrEntity, stage.uploadId, publish);
return stage;
}
function completeW600WeeklyScheduleUpload(deviceOrEntity, publish) {
const storeKey = getW600DeviceStoreKey(deviceOrEntity);
const stage = globalStore.getValue(storeKey, W600_WEEKLY_SCHEDULE_OTA_STAGE_STORE_KEY);
const operation = stage?.operation === "clear_schedule" ? "clear_schedule" : "save_schedule";
clearW600WeeklyScheduleUploadTimeout(storeKey);
globalStore.clearValue(storeKey, W600_WEEKLY_SCHEDULE_OTA_STAGE_STORE_KEY);
logger_1.logger.info(`W600 ${describeW600WeeklyScheduleOperation(operation)} completed for ${storeKey}`, W600_NS);
return updateW600WeeklyScheduleUploadState(storeKey, { status: "success", error: "", operation }, publish);
}
function getActiveW600WeeklyScheduleOtaStage(deviceOrEntity) {
const storeKey = getW600DeviceStoreKey(deviceOrEntity);
const stage = globalStore.getValue(storeKey, W600_WEEKLY_SCHEDULE_OTA_STAGE_STORE_KEY);
if (!stage || !node_buffer_1.Buffer.isBuffer(stage.image) || typeof stage.createdAt !== "number") {
return undefined;
}
const lastActivityAt = typeof stage.lastActivityAt === "number" ? stage.lastActivityAt : stage.createdAt;
if (Date.now() - lastActivityAt > W600_WEEKLY_SCHEDULE_OTA_STAGE_TTL_MS) {
failW600WeeklyScheduleUpload(storeKey, "Weekly schedule OTA stage expired before the transfer completed");
return undefined;
}
return stage;
}
function ensureNoActiveW600WeeklyScheduleUpload(deviceOrEntity) {
const stage = getActiveW600WeeklyScheduleOtaStage(deviceOrEntity);
if (!stage) {
return;
}
const uploadState = normalizeW600WeeklyScheduleUploadState(globalStore.getValue(getW600DeviceStoreKey(deviceOrEntity), W600_WEEKLY_SCHEDULE_UPLOAD_STATE_STORE_KEY));
throw new Error(`A weekly schedule upload is already active (${uploadState.status}) for ${describeW600WeeklyScheduleOperation(stage.operation)}. ` +
"Wait for it to finish before starting another save or clear.");
}
function getCachedW600WeeklyScheduleDraft(entity, meta) {
const draft = createEmptyW600WeeklyScheduleDraft();
const storeKey = getW600DeviceStoreKey(entity);
const cached = globalStore.getValue(storeKey, W600_WEEKLY_SCHEDULE_DRAFT_STORE_KEY);
for (const { property } of W600_WEEKLY_SCHEDULE_DAY_DEFINITIONS) {
if (typeof cached?.[property] === "string") {
draft[property] = cached[property];
}
else if (typeof meta.state?.[property] === "string") {
draft[property] = meta.state[property];
}
}
return draft;
}
function seedW600WeeklyScheduleDraftState(deviceOrEntity, state) {
const draft = getCachedW600WeeklyScheduleDraft(deviceOrEntity, { state });
Object.assign(state, buildW600WeeklyScheduleStatePayload(draft));
}
function stageW600WeeklyScheduleUpload(entity, draft, image, operation, recordCount, publish) {
const storeKey = getW600DeviceStoreKey(entity);
const uploadId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}`;
globalStore.putValue(storeKey, W600_WEEKLY_SCHEDULE_DRAFT_STORE_KEY, draft);
globalStore.putValue(storeKey, W600_WEEKLY_SCHEDULE_OTA_STAGE_STORE_KEY, {
createdAt: Date.now(),
lastActivityAt: Date.now(),
image,
operation,
recordCount,
uploadId,
});
armW600WeeklyScheduleUploadTimeout(storeKey, uploadId, publish);
return updateW600WeeklyScheduleUploadState(storeKey, { status: "staged", error: "", operation, recordCount, uploadId });
}
function getNumericW600OtaRequestField(data, key) {
const value = data?.[key];
if (value == null) {
return undefined;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : undefined;
}
function matchesW600WeeklyScheduleOtaRequest(data, requireFileVersion = false) {
if (getNumericW600OtaRequestField(data, "manufacturerCode") !== exports.manufacturerCode ||
getNumericW600OtaRequestField(data, "imageType") !== W600_WEEKLY_SCHEDULE_IMAGE_TYPE) {
return false;
}
return !requireFileVersion || getNumericW600OtaRequestField(data, "fileVersion") === W600_WEEKLY_SCHEDULE_FILE_VERSION;
}
function createW600ExternalTempSensor() {
const readSensorState = async (entity) => {
await readW600LumiAttribute(entity, W600_ATTR_SENSOR_SOURCE);
};
return {
exposes: [
e
.temperature_sensor_select(["internal", "external"])
.withAccess(ea.ALL)
.withLabel("Temperature source")
.withDescription("Choose whether the thermostat uses its internal sensor or data provided via 'External Sensor Temperature'"),
e
.external_temperature_input()
.withValueMin(-40)
.withValueMax(125)
.withValueStep(0.01)
.withDescription("Manual external temperature forwarded to the W600 when temperature source is external")
.withCategory("config"),
],
fromZigbee: [
{
cluster: W600_LUMI_CLUSTER,
type: ["attributeReport", "readResponse"],
convert: (model, msg) => {
const result = {};
if (msg.data[W600_ATTR_SENSOR_SOURCE] === 0 || msg.data[W600_ATTR_SENSOR_SOURCE] === 1) {
result.sensor = msg.data[W600_ATTR_SENSOR_SOURCE] === 1 ? "external" : "internal";
}
return Object.keys(result).length > 0 ? result : undefined;
},
},
],
toZigbee: [
{
key: ["sensor"],
convertSet: async (entity, key, value) => {
(0, utils_1.assertEndpoint)(entity);
const sensor = parseW600SensorSelection(value, key);
if (sensor === "external") {
await writeW600LumiAttribute(entity, W600_ATTR_SENSOR_BINDING, buildW600ExternalTempSensorBindPayload(entity), zigbee_herdsman_1.Zcl.DataType.OCTET_STR);
await writeW600LumiAttribute(entity, W600_ATTR_SENSOR_SOURCE, 1);
return { state: { sensor: "external" } };
}
await writeW600LumiAttribute(entity, W600_ATTR_SENSOR_SOURCE, 0);
await writeW600LumiAttribute(entity, W600_ATTR_SENSOR_BINDING, buildW600ExternalTempSensorUnbindPayload(entity), zigbee_herdsman_1.Zcl.DataType.OCTET_STR);
return { state: { sensor: "internal" } };
},
convertGet: async (entity) => {
(0, utils_1.assertEndpoint)(entity);
await readSensorState(entity);
},
},
{
key: ["external_temperature_input"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertEndpoint)(entity);
const requestedSensor = meta.message?.sensor != null ? parseW600SensorSelection(meta.message.sensor, "sensor") : undefined;
const currentSensor = getW600SensorSelectionFromState(meta.state?.sensor);
const sensor = requestedSensor ?? currentSensor;
if (sensor !== "external") {
throw new Error("external_temperature_input can only be used when sensor is external");
}
const shouldRefreshBinding = currentSensor !== "external" || requestedSensor === "external";
if (shouldRefreshBinding) {
await writeW600LumiAttribute(entity, W600_ATTR_SENSOR_BINDING, buildW600ExternalTempSensorBindPayload(entity), zigbee_herdsman_1.Zcl.DataType.OCTET_STR);
await writeW600LumiAttribute(entity, W600_ATTR_SENSOR_SOURCE, 1);
}
const centiDegrees = parseW600ExternalTemperatureInput(value, key);
await writeW600LumiAttribute(entity, W600_ATTR_SENSOR_BINDING, buildW600ExternalTemperaturePayload(entity, centiDegrees), zigbee_herdsman_1.Zcl.DataType.OCTET_STR);
return {
state: {
external_temperature_input: centiDegrees / 100,
...(shouldRefreshBinding ? { sensor: "external" } : {}),
},
};
},
convertGet: async (entity) => {
(0, utils_1.assertEndpoint)(entity);
await readSensorState(entity);
},
},
],
configure: [
async (device) => {
const endpoint = device.getEndpoint(1);
await safeW600Read(endpoint, W600_LUMI_CLUSTER, [W600_ATTR_SENSOR_SOURCE], { manufacturerCode: exports.manufacturerCode });
},
],
isModernExtend: true,
};
}
function createW600Thermostat() {
const extend = modernExtend.thermostat({
localTemperature: { values: { description: "Current temperature used by the thermostat" } },
setpoints: {
values: { occupiedHeatingSetpoint: { min: 5, max: 30, step: 0.5 } },
},
localTemperatureCalibration: { values: { min: -5, max: 5, step: 0.1 } },
temperatureSetpointHoldDuration: true,
systemMode: { values: ["off", "heat", "auto"], configure: { skip: true } },
runningState: { values: ["idle", "heat"], toZigbee: { skip: true }, configure: { skip: true } },
});
const climateExpose = findW600ClimateExpose(extend);
climateExpose?.withPreset([...W600_PRESET_ORDER], "Selected preset scene");
climateExpose?.setAccess("preset", ea.ALL);
const holdDurationExpose = extend.exposes?.find((expose) => typeof expose !== "function" && expose.type === "numeric" && expose.property === "temperature_setpoint_hold_duration");
holdDurationExpose
?.withLabel("Manual Override Duration")
.withUnit("min")
.withCategory("config")
.withDescription("Duration in minutes for the current manual override. 0 means until next schedule event, 65535 means indefinitely.");
extend.exposes?.push(e.binary("override_active", ea.STATE, true, false).withLabel("Manual Override").withDescription("Temporary manual override active"), e
.numeric("local_temperature_internal", ea.STATE)
.withUnit("°C")
.withLabel("Internal sensor temperature")
.withDescription("Temperature measured by the thermostat's internal sensor")
.withCategory("diagnostic"));
const thermostatConverter = {
cluster: W600_THERMOSTAT_CLUSTER,
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const result = fz.thermostat.convert(model, msg, publish, options, meta);
if (msg.endpoint.ID === 2) {
return result?.local_temperature === undefined ? undefined : { local_temperature_internal: result.local_temperature };
}
if (result && msg.data.tempSetpointHold !== undefined) {
const holdProperty = (0, utils_1.postfixWithEndpointName)("temperature_setpoint_hold", msg, model, meta);
result.override_active = msg.data.tempSetpointHold === 1;
delete result[holdProperty];
}
return result;
},
};
const occupiedHeatingSetpointConverter = {
key: ["occupied_heating_setpoint"],
options: tz.thermostat_occupied_heating_setpoint.options,
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertEndpoint)(entity);
const result = await tz.thermostat_occupied_heating_setpoint.convertSet(entity, key, value, meta);
const resultState = result && "state" in result ? result.state : undefined;
const shouldUseHold = getRequestedW600ScheduleEnabled(meta) !== false;
if (shouldUseHold) {
await entity.write(W600_THERMOSTAT_CLUSTER, { tempSetpointHold: 1 });
}
startW600ManualCustomPresetSuppression(entity);
return {
state: {
...(resultState ?? {}),
...(shouldUseHold ? { system_mode: "auto", schedule: "ON", override_active: true } : {}),
preset: "none",
},
};
},
convertGet: async (entity, key, meta) => {
(0, utils_1.assertEndpoint)(entity);
await tz.thermostat_occupied_heating_setpoint.convertGet?.(entity, key, meta);
},
};
const systemModeConverter = {
key: ["system_mode"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertEndpoint)(entity);
const normalized = parseW600EnumName(value, { off: 0, heat: 1, auto: 2 }, key);
if (normalized === "off") {
clearW600ManualCustomPresetSuppression(entity);
await writeW600LumiAttribute(entity, W600_ATTR_SYSTEM_MODE, 0);
await writeW600LumiAttribute(entity, W600_ATTR_SCHEDULE, 0);
await entity.write(W600_THERMOSTAT_CLUSTER, { tempSetpointHold: 0 });
return { state: { system_mode: "off", schedule: "OFF", override_active: false, running_state: "idle" } };
}
await writeW600LumiAttribute(entity, W600_ATTR_SYSTEM_MODE, 1);
if (normalized === "auto") {
clearW600ManualCustomPresetSuppression(entity);
await writeW600LumiAttribute(entity, W600_ATTR_SCHEDULE, 1);
await entity.write(W600_THERMOSTAT_CLUSTER, { tempSetpointHold: 0 });
return { state: { system_mode: "auto", schedule: "ON", override_active: false } };
}
clearW600ManualCustomPresetSuppression(entity);
await writeW600LumiAttribute(entity, W600_ATTR_SCHEDULE, 0);
await entity.write(W600_THERMOSTAT_CLUSTER, { tempSetpointHold: 0 });
return { state: { system_mode: "heat", schedule: "OFF", override_active: false } };
},
convertGet: async (entity) => {
(0, utils_1.assertEndpoint)(entity);
await readW600LumiAttribute(entity, W600_ATTR_SYSTEM_MODE);
await readW600LumiAttribute(entity, W600_ATTR_SCHEDULE);
await entity.read(W600_THERMOSTAT_CLUSTER, ["tempSetpointHold"]);
},
};
const presetConverter = {
key: ["preset"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertEndpoint)(entity);
const normalized = parseW600EnumName(value, { none: 0, ...W600_PRESET_ID_BY_NAME }, key);
const scheduleEnabled = getRequestedW600ScheduleEnabled(meta) !== false;
if (normalized === "none") {
startW600ManualCustomPresetSuppression(entity);
if (scheduleEnabled) {
await entity.write(W600_THERMOSTAT_CLUSTER, { tempSetpointHold: 1 });
return { state: { system_mode: "auto", schedule: "ON", preset: "none", override_active: true } };
}
return { state: { preset: "none" } };
}
clearW600ManualCustomPresetSuppression(entity);
await writeW600LumiAttribute(entity, W600_ATTR_PRESET, W600_PRESET_ID_BY_NAME[normalized]);
if (scheduleEnabled) {
await entity.write(W600_THERMOSTAT_CLUSTER, { tempSetpointHold: 1 });
return { state: { system_mode: "auto", schedule: "ON", preset: normalized, override_active: true } };
}
return { state: { preset: normalized } };
},
convertGet: async (entity) => {
(0, utils_1.assertEndpoint)(entity);
await readW600LumiAttribute(entity, W600_ATTR_PRESET);
},
};
const holdDurationConverter = {
key: ["temperature_setpoint_hold_duration"],
convertSet: async (entity, key, value) => {
(0, utils_1.assertEndpoint)(entity);
const duration = Number(value);
if (!Number.isInteger(duration) || duration < 0 || duration > 65535) {
throw new Error(`${key} must be an integer between 0 and 65535`);
}
await entity.write(W600_THERMOSTAT_CLUSTER, { [W600_ATTR_TEMP_SETPOINT_HOLD_DURATION]: { value: duration, type: zigbee_herdsman_1.Zcl.DataType.UINT16 } }, { writeUndiv: true });
return { state: { temperature_setpoint_hold_duration: duration } };
},
convertGet: async (entity) => {
(0, utils_1.assertEndpoint)(entity);
await entity.read(W600_THERMOSTAT_CLUSTER, ["tempSetpointHoldDuration"]);
},
};
const runningStateConverter = {
key: ["running_state"],
convertGet: async (entity) => {
(0, utils_1.assertEndpoint)(entity);
await readW600LumiAttribute(entity, W600_ATTR_VALVE_POSITION);
},
};
extend.toZigbee = (0, utils_1.replaceToZigbeeConvertersInArray)(extend.toZigbee ?? [], [tz.thermostat_occupied_heating_setpoint, tz.thermostat_system_mode, tz.thermostat_temperature_setpoint_hold_duration], [occupiedHeatingSetpointConverter, systemModeConverter, holdDurationConverter]);
extend.toZigbee ??= [];
extend.toZigbee.push(presetConverter, runningStateConverter);
extend.fromZigbee = (extend.fromZigbee ?? []).map((converter) => (converter === fz.thermostat ? thermostatConverter : converter));
extend.fromZigbee.push({
cluster: W600_THERMOSTAT_CLUSTER,
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const device = meta.device ?? msg.device;
const result = {};
const hold = msg.data.tempSetpointHold !== undefined ? msg.data.tempSetpointHold === 1 : getW600OverrideActiveState(meta.state);
const heatingEnabled = parseW600HeatingEnabled(meta.state?.system_mode);
const scheduleEnabled = parseW600ScheduleEnabled(meta.state?.schedule);
if (msg.data.tempSetpointHold === 0) {
clearW600ManualCustomPresetSuppression(device);
}
if (msg.data.tempSetpointHold !== undefined) {
result.override_active = hold;
const systemMode = deriveW600SystemMode({ heatingEnabled, scheduleEnabled });
if (systemMode) {
result.system_mode = systemMode;
}
}
if (isW600ManualCustomPresetSuppressionActive(device) && (msg.data.occupiedHeatingSetpoint !== undefined || hold === true)) {
result.preset = "none";
}
return Object.keys(result).length > 0 ? result : undefined;
},
}, {
cluster: W600_LUMI_CLUSTER,
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const device = meta.device ?? msg.device;
const result = {};
const heatingEnabled = msg.data[W600_ATTR_SYSTEM_MODE] !== undefined
? msg.data[W600_ATTR_SYSTEM_MODE] === 1
: parseW600HeatingEnabled(meta.state?.system_mode);
const scheduleEnabled = msg.data[W600_ATTR_SCHEDULE] !== undefined ? msg.data[W600_ATTR_SCHEDULE] === 1 : parseW600ScheduleEnabled(meta.state?.schedule);
const runningState = deriveW600RunningStateFromValvePosition(msg.data[W600_ATTR_VALVE_POSITION]);
if (runningState) {
result.running_state = runningState;
}
if (msg.data[W600_ATTR_SYSTEM_MODE] !== undefined || msg.data[W600_ATTR_SCHEDULE] !== undefined) {
const systemMode = deriveW600SystemMode({ heatingEnabled, scheduleEnabled });
if (systemMode) {
result.system_mode = systemMode;
}
if (heatingEnabled === false) {
clearW600ManualCustomPresetSuppression(device);
result.schedule = "OFF";
result.override_active = false;
result.running_state = "idle";
if (parseW600ScheduleEnabled(meta.state?.schedule) !== false) {
writeW600LumiAttribute(msg.endpoint, W600_ATTR_SCHEDULE, 0).catch((error) => logger_1.logger.warning(`Failed to disable W600 schedule after heating was turned off for '${device.ieeeAddr}': ${error}`, W600_NS));
msg.endpoint
.write(W600_THERMOSTAT_CLUSTER, { tempSetpointHold: 0 })
.catch((error) => logger_1.logger.warning(`Failed to clear W600 manual override after heating was turned off for '${device.ieeeAddr}': ${error}`, W600_NS));
}
}
}
const presetValue = msg.data[W600_ATTR_PRESET];
if (typeof presetValue === "number" && Object.hasOwn(W600_PRESET_BY_ID, presetValue)) {
if (presetValue === 255) {
startW600ManualCustomPresetSuppression(device);
result.preset = "none";
}
else if (isW600ManualCustomPresetSuppressionActive(device)) {
result.preset = "none";
}
else {
clearW600ManualCustomPresetSuppression(device);
result.preset = W600_PRESET_BY_ID[presetValue];
}
}
return Object.keys(result).length > 0 ? result : undefined;
},
});
extend.configure ??= [];
extend.configure.push(async (device) => {
const endpoint = device.getEndpoint(1);
await safeW600Read(endpoint, W600_LUMI_CLUSTER, [W600_ATTR_SYSTEM_MODE, W600_ATTR_SCHEDULE, W600_ATTR_PRESET], { manufacturerCode: exports.manufacturerCode });
await safeW600Read(endpoint, W600_THERMOSTAT_CLUSTER, ["tempSetpointHold"]);
});
return extend;
}
function createW600ValvePosition() {
return modernExtend.numeric({
name: "position",
valueMin: 0,
valueMax: 100,
scale: 1,
precision: 2,
unit: "%",
access: "STATE_GET",
cluster: W600_LUMI_CLUSTER,
attribute: { ID: W600_ATTR_VALVE_POSITION, type: zigbee_herdsman_1.Zcl.DataType.SINGLE_PREC },
description: "Position of the valve, 100% is fully open",
label: "Valve position",
zigbeeCommandOptions: { manufacturerCode: exports.manufacturerCode },
});
}
function createW600Schedule() {
return {
fromZigbee: [
{
cluster: W600_LUMI_CLUSTER,
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data[W600_ATTR_SCHEDULE] === undefined) {
return;
}
const heatingEnabled = msg.data[W600_ATTR_SYSTEM_MODE] !== undefined
? msg.data[W600_ATTR_SYSTEM_MODE] === 1
: parseW600HeatingEnabled(meta.state?.system_mode);
if (heatingEnabled === false) {
return buildW600ScheduleState(false);
}
return buildW600ScheduleState(msg.data[W600_ATTR_SCHEDULE] === 1);
},
},
],
toZigbee: [
{
key: ["schedule"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertEndpoint)(entity);
const enabled = parseRequiredW600BinaryEnabled(value, key);
const heatingEnabled = parseW600HeatingEnabled(meta.state?.system_mode);
if (enabled) {
await writeW600LumiAttribute(entity, W600_ATTR_SCHEDULE, 1);
const state = buildW600ScheduleState(true);
const systemMode = deriveW600SystemMode({
heatingEnabled,
scheduleEnabled: true,
});
if (systemMode) {
state.system_mode = systemMode;
}
return { state };
}
clearW600ManualCustomPresetSuppression(entity);
await entity.write(W600_THERMOSTAT_CLUSTER, { tempSetpointHold: 0 });
await writeW600LumiAttribute(entity, W600_ATTR_SCHEDULE, 0);
const state = { ...buildW600ScheduleState(false), override_active: false };
const systemMode = deriveW600SystemMode({ heatingEnabled, scheduleEnabled: false });
if (systemMode) {
state.system_mode = systemMode;
}
return { state };
},
convertGet: async (entity) => {
(0, utils_1.assertEndpoint)(entity);
await readW600LumiAttribute(entity, W600_ATTR_SCHEDULE);
},
},
],
configure: [
async (device) => {
const endpoint = device.getEndpoint(1);
await safeW600Read(endpoint, W600_LUMI_CLUSTER, [W600_ATTR_SCHEDULE], { manufacturerCode: exports.manufacturerCode });
},
],
isModernExtend: true,
};
}
function createW600WeeklySchedule() {
const dayDescription = "Staged weekly schedule for this day. Use comma-delimited entries in the format HH:MM/preset, for example '08:00/home, 19:00/vacation'. Editing the text fields does not upload anything until Save schedule is triggered.";
const uploadStatusDescription = "Current state of the custom OTA transfer used to upload the weekly schedule to the thermostat.";
const onEvent = [
(event) => {
const shouldSeedDraft = event.type === "start" ||
event.type === "deviceJoined" ||
(event.type === "deviceInterview" && (event.data.status === "started" || event.data.status === "successful"));
if (!shouldSeedDraft) {
return;
}
seedW600WeeklyScheduleDraftState(event.data.device, event.data.state);
const uploadState = normalizeW600WeeklyScheduleUploadState(globalStore.getValue(getW600DeviceStoreKey(event.data.device), W600_WEEKLY_SCHEDULE_UPLOAD_STATE_STORE_KEY));
globalStore.putValue(getW600DeviceStoreKey(event.data.device), W600_WEEKLY_SCHEDULE_UPLOAD_STATE_STORE_KEY, uploadState);
event.data.state.schedule_upload_status = uploadState.status;
if (event.type === "start") {
const endpoint = event.data.device.getEndpoint(1);
void safeW600Read(endpoint, W600_LUMI_CLUSTER, [W600_ATTR_SCHEDULE], { manufacturerCode: exports.manufacturerCode });
}
},
];
return {
exposes: [
...W600_WEEKLY_SCHEDULE_DAY_DEFINITIONS.map(({ label, property }) => e.text(property, ea.STATE_SET).withLabel(`${label} schedule`).withDescription(dayDescription).withCategory("config")),
e
.enum("schedule_upload_status", ea.STATE, [...W600_WEEKLY_SCHEDULE_UPLOAD_STATUSES])
.withLabel("Schedule upload status")
.withDescription(uploadStatusDescription)
.withCategory("diagnostic"),
e
.enum("save_schedule", ea.SET, ["trigger"])
.withLabel("Save schedule")
.withDescription("Upload the weekly schedule to the thermostat")
.withCategory("config"),
e
.enum("clear_schedule", ea.SET, ["trigger"])
.withLabel("Clear schedule")
.withDescription("Clear all weekly schedule inputs and upload an empty schedule to the thermostat")
.withCategory("config"),
],
fromZigbee: [
{
cluster: W600_LUMI_CLUSTER,
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data[W600_ATTR_SCHEDULE] === undefined) {
return;
}
const device = meta.device ?? msg.device;
return getW600WeeklyScheduleUploadStatePayload(device);
},
},
{
cluster: "genOta",
type: ["commandQueryNextImageRequest"],
convert: async (model, msg, publish, options, meta) => {
const device = meta.device ?? msg.device;
const stage = getActiveW600WeeklyScheduleOtaStage(device);
if (!stage) {
return;
}
if (!matchesW600WeeklyScheduleOtaRequest(msg.data)) {
await msg.endpoint.commandResponse("genOta", "queryNextImageResponse", { status: zigbee_herdsman_1.Zcl.Status.NO_IMAGE_AVAILABLE }, undefined, msg.meta.zclTransactionSequenceNumber);
return;
}
markW600WeeklyScheduleUploadStarted(device, publish);
await msg.endpoint.commandResponse("genOta", "queryNextImageResponse", {
status: zigbee_herdsman_1.Zcl.Status.SUCCESS,
manufacturerCode: exports.manufacturerCode,
imageType: W600_WEEKLY_SCHEDULE_IMAGE_TYPE,
fileVersion: W600_WEEKLY_SCHEDULE_FILE_VERSION,
imageSize: stage.image.length,
}, undefined, msg.meta.zclTransactionSequenceNumber);
},
},
{
cluster: "genOta",
type: ["commandImageBlockRequest"],
convert: async (model, msg, publish, options, meta) => {
const device = meta.device ?? msg.device;
const stage = getActiveW600WeeklyScheduleOtaStage(device);
if (!stage) {
return;
}
if (!matchesW600WeeklyScheduleOtaRequest(msg.data, true)) {
await msg.endpoint.commandResponse("genOta", "imageBlockResponse", { status: zigbee_herdsman_1.Zcl.Status.INVALID_IMAGE }, undefined, msg.meta.zclTransactionSequenceNumber);
return;
}
markW600WeeklyScheduleUploadStarted(device, publish);
const fileOffset = Number(msg.data.fileOffset);
const maximumDataSize = Number(msg.data.maximumDataSize);
if (!Number.isInteger(fileOffset) ||
!Number.isInteger(maximumDataSize) ||
fileOffset < 0 ||
maximumDataSize <= 0 ||
fileOffset >= stage.image.length) {
failW600WeeklyScheduleUpload(device, `Received invalid image block request (offset=${String(msg.data.fileOffset)}, maximumDataSize=${String(msg.data.maximumDataSize)})`, publish);
await msg.endpoint.commandResponse("genOta", "imageBlockResponse", { status: zigbee_herdsman_1.Zcl.Status.ABORT }, undefined, msg.meta.zclTransactionSequenceNumber);
return;
}
const chunk = stage.image.subarray(fileOffset, Math.min(stage.image.length, fileOffset + maximumDataSize));
noteW600WeeklyScheduleUploadBlock(device, publish);
await msg.endpoint.commandResponse("genOta", "imageBlockResponse", {
status: zigbee_herdsman_1.Zcl.Status.SUCCESS,
manufacturerCode: exports.manufacturerCode,
imageType: W600_WEEKLY_SCHEDULE_IMAGE_TYPE,
fileVersion: W600_WEEKLY_SCHEDULE_FILE_VERSION,
fileOffset,
dataSize: chunk.length,
data: chunk,
}, undefined, msg.meta.zclTransactionSequenceNumber);
},
},
{
cluster: "genOta",
type: ["commandUpgradeEndRequest"],
convert: async (model, msg, publish, options, meta) => {
const device = meta.device ?? msg.device;
const stage = getActiveW600WeeklyScheduleOtaStage(device);
if (!stage) {
return;
}
if (!matchesW600WeeklyScheduleOtaRequest(msg.data, true)) {
return;
}
const upgradeStatus = getNumericW600OtaRequestField(msg.data, "status");
if (upgradeStatus != null && upgradeStatus !== 0) {
failW600WeeklyScheduleUpload(device, `Device ended the OTA transfer with status ${String(msg.data.status)}`, publish);
return;
}
await msg.endpoint.commandResponse("genOta", "upgradeEndResponse", {
manufacturerCode: exports.manufacturerCode,
imageType: W600_WEEKLY_SCHEDULE_IMAGE_TYPE,
fileVersion: W600_WEEKLY_SCHEDULE_FILE_VERSION,
currentTime: 0,
upgradeTime: 0,
}, undefined, msg.meta.zclTransactionSequenceNumber);
completeW600WeeklyScheduleUpload(device, publish);
},
},
],
toZigbee: [
{
key: [...W600_WEEKLY_SCHEDULE_DAY_PROPERTIES],
convertSet: (entity, key, value, meta) => {
(0, utils_1.assertEndpoint)(entity);
const draft = getCachedW600WeeklyScheduleDraft(entity, meta);
const transitions = parseW600ScheduleDayTransitions(value, key);
draft[key] = formatW600ScheduleDayTransitions(transitions);
globalStore.putValue(getW600DeviceStoreKey(entity), W600_WEEKLY_SCHEDULE_DRAFT_STORE_KEY, draft);
return { state: { [key]: draft[key] === "" ? null : draft[key] } };
},
},
{
key: ["save_schedule"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertEndpoint)(entity);
parseW600ScheduleTriggerValue(value, key);
ensureNoActiveW600WeeklyScheduleUpload(entity);
const draft = getCachedW600WeeklyScheduleDraft(entity, meta);
for (const property of W600_WEEKLY_SCHEDULE_DAY_PROPERTIES) {
if (meta.message?.[property] !== undefined) {
draft[property] = meta.message[property];
}
}
const normalized = normalizeW600WeeklyScheduleDraft(draft);
const image = buildW600WeeklyScheduleImage(normalized.records);
const uploadState = stageW600WeeklyScheduleUpload(entity, normalized.draft, image, "save_schedule", normalized.records.length, meta.publish);
logger_1.logger.info(`Staged W600 save schedule upload for ${getW600DeviceStoreKey(entity)} with ${normalized.records.length} entries (${image.length} bytes)`, W600_NS);
try {
await entity.commandResponse("genOta", "imageNotify", { payloadType: 0, queryJitter: W600_WEEKLY_SCHEDULE_IMAGE_NOTIFY_QUERY_JITTER }, { sendPolicy: "immediate" });
}
catch (error) {
const details = error instanceof Error ? error.message : String(error);
failW600WeeklyScheduleUpload(entity, `Failed to send imageNotify: ${details}`, meta.publish);
throw error;
}
return { state: { ...buildW600WeeklyScheduleStatePayload(normalized.draft), ...uploadState.state } };
},
},
{
key: ["clear_schedule"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertEndpoint)(entity);
parseW600ScheduleTriggerValue(value, key);
ensureNoActiveW600WeeklyScheduleUpload(entity);
const draft = createEmptyW600WeeklyScheduleDraft();
const image = buildW600WeeklyScheduleImage([]);
const uploadState = stageW600WeeklyScheduleUpload(entity, draft, image, "clear_schedule", 0, meta.publish);
logger_1.logger.info(`Staged W600 clear schedule upload for ${getW600DeviceStoreKey(entity)} (${image.length} bytes)`, W600_NS);
try {
await entity.commandResponse("genOta", "imageNotify", { payloadType: 0, queryJitter: W600_WEEKLY_SCHEDULE_IMAGE_NOTIFY_QUERY_JITTER }, { sendPolicy: "immediate" });
}
catch (error) {
const details = error instanceof Error ? error.message : String(error);
failW600WeeklyScheduleUpload(entity, `Failed to send imageNotify: ${details}`, meta.publish);
throw error;
}
return { state: { ...buildW600WeeklyScheduleStatePayload(draft), ...uploadState.state } };
},
},
],
onEvent,
isModernExtend: true,
};
}
function createW600PresetTemperatureTable() {
return {
exposes: W600_PRESET_TEMPERATURE_DEFINITIONS.map(({ property, label, description }) => e
.numeric(property, ea.ALL)
.withLabel(label)
.withValueMin(5)
.withValueMax(30)
.withValueStep(0.5)
.withUnit("°C")
.withDescription(description)
.withCategory("config")),
fromZigbee: [
{
cluster: W600_LUMI_CLUSTER,
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const value = msg.data[W600_ATTR_PRESET_TEMPERATURE_TABLE];
if (!node_buffer_1.Buffer.isBuffer(value) || value.length === 0) {
return;
}
const table = decodeW600PresetTemperatureTable(value);
if (!table) {
return;
}
const device = meta.device ?? msg.device;
globalStore.putValue(getW600DeviceStoreKey(device), W600_PRESET_TABLE_STORE_KEY, table);
const result = {};
for (const [presetName, centiDegrees] of Object.entries(table)) {
result[W600_PROPERTY_BY_PRESET_NAME[presetName]] = centiDegrees / 100;
}
return result;
},
},
],
toZigbee: [
{
key: W600_PRESET_TEMPERATURE_DEFINITIONS.map(({ property }) => property),
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertEndpoint)(entity);
const presetName = W600_PRESET_NAME_BY_PROPERTY[key];
const table = getCachedW600PresetTemperatureTable(entity, meta);
if (!table) {
throw new Error("Preset temperature table is unknown. Read the preset temperature properties first.");
}
table[presetName] = parseW600HalfDegreeTemperature(value, key, 5, 30);
await writeW600LumiAttribute(entity, W600_ATTR_PRESET_TEMPERATURE_TABLE, encodeW600PresetTemperatureTable(table), zigbee_herdsman_1.Zcl.DataType.OCTET_STR);
globalStore.putValue(getW600DeviceStoreKey(entity), W600_PRESET_TABLE_STORE_KEY, table);
return { state: { [key]: table[presetName] / 100 } };
},
convertGet: async (entity) => {
(0, utils_1.assertEndpoint)(entity);
await readW600LumiAttribute(entity, W600_ATTR_PRESET_TEMPERATURE_TABLE);
},
},
],
configure: [
async (device) => {
const endpoint = device.getEndpoint(1);
await safeW600Read(endpoint, W600_LUMI_CLUSTER, [W600_ATTR_PRESET_TEMPERATURE_TABLE], { manufacturerCode: exports.manufacturerCode });
},
],
isModernExtend: true,
};
}
const feederDaysLookup = {
127: "everyday",
31: "workdays",
96: "weekend",
1: "mon",
2: "tue",
4: "wed",
8: "thu",
16: "fri",
32: "sat",
64: "sun",
85: "mon-wed-fri-sun",
42: "tue-thu-sat",
};
const w100Defaults = {
system_mode: "off",
occupied_heating_setpoint: 15,
fan_mode: "auto",
unused: "0",
thermostat_mode: "OFF",
min_target_temp: 5,
max_target_temp: 30,
};
function w100EnsureDefaults(meta) {
const state = meta.state || {};
const normalized = {
system_mode: state.system_mode ?? w100Defaults.system_mode,
occupied_heating_setpoint: state.occupied_heating_setpoint ?? w100Defaults.occupied_heating_setpoint,
fan_mode: state.fan_mode ?? w100Defaults.fan_mode,
unused: state.unused ?? w100Defaults.unused,
thermostat_mode: state.thermostat_mode ?? w100Defaults.thermostat_mode,
};
meta.state = {
...state,
...normalized,
};
if (meta.device) {
if (!meta.device.meta)
meta.device.meta = {};
if (!meta.device.meta.initialized) {
meta.device.meta.initialized = true;
}
}
return normalized;
}
async function w100SendPMTSD(entity, pmtsd, meta) {
const { p, m, t, s, d } = pmtsd;
const pmtsdStr = `P${p}_M${m}_T${t}_S${s}_D${d}`;
const pmtsdBytes = Array.from(pmtsdStr).map((c) => c.charCodeAt(0));
const pmtsdLen = pmtsdBytes.length;
const fixedHeader = [
0xaa,
0x71,
0x1f,
0x44,
0x00,
0x00,
0x05,
0x41,
0x1c,
0x00,
0x00,
0x54,
0xef,
0x44,
0x80,
0x71,
0x1a,
0x08,
0x00,
0x08,
0x44,
pmtsdLen,
];
const counter = Math.floor(Math.random() * 256);
fixedHeader[4] = counter;
const fullPayload = [...fixedHeader, ...pmtsdBytes];
const checksum = fullPayload.reduce((sum, b) => sum + b, 0) & 0xff;
fixedHeader[5] = checksum;
await entity.write(64704, { 65522: { value: node_buffer_1.Buffer.from(fullPayload), type: 65 } }, { manufacturerCode: 4447, disableDefaultResponse: true });
logger_1.logger.info(`Aqara W100: PMTSD frame sent: ${pmtsdStr}`, NS);
if (meta.device) {
if (!meta.device.meta)
meta.device.meta = {};
meta.device.meta.lastPMTSDSend = Date.now();
}
}
exports.fromZigbee = {
// lumi generic
lumi_basic: {
cluster: "genBasic",
type: ["attributeReport", "readResponse"],
convert: async (model, msg, publish, options, meta) => {
return await (0, exports.numericAttributes2Payload)(msg, meta, model, options, msg.data);
},
},
lumi_basic_raw: {
cluster: "genBasic",
type: ["raw"],
convert: async (model, msg, publish, options, meta) => {
let payload = {};
if (node_buffer_1.Buffer.isBuffer(msg.data)) {
const dataObject = (0, exports.buffer2DataObject)(model, msg.data);
payload = await (0, exports.numericAttributes2Payload)(msg, meta, model, options, dataObject);
}
return payload;
},
},
lumi_specific: {
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: async (model, msg, publish, options, meta) => {
return await (0, exports.numericAttributes2Payload)(msg, meta, model, options, msg.data);
},
},
lumi_co2: {
cluster: "msCO2",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
return { co2: Math.floor(msg.data.measuredValue) };
},
},
lumi_pm25: {
cluster: "pm25Measurement",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data.measuredValue) {
return { pm25: msg.data.measuredValue };
}
},
},
lumi_contact: {
cluster: "genOnOff",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
return { contact: msg.data.onOff === 0 };
},
},
lumi_power: {
cluster: "genAnalogInput",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
return { power: msg.data.presentValue };
},
},
lumi_action: {
cluster: "genOnOff",
type: ["attributeReport"],
convert: (model, msg, publish, options, meta) => {
if (["QBKG04LM", "QBKG11LM", "QBKG21LM", "QBKG03LM", "QBKG12LM", "QBKG22LM"].includes(model.model) && msg.data["61440"]) {
return;
}
if (model.model === "WXKG11LM") {
let clicks;
if (msg.data.onOff) {
clicks = 1;
}
else if (msg.data["32768"]) {
clicks = msg.data["32768"];
}
const actionLookup = { 1: "single", 2: "double", 3: "triple", 4: "quadruple" };
if (actionLookup[clicks]) {
return { action: actionLookup[clicks] };
}
return;
}
if (["QBKG21LM", "QBKG04LM"].includes(model.model) && msg.endpoint.ID !== 4)
return;
let mapping = null;
if (["QBKG03LM", "QBKG12LM", "QBKG22LM"].includes(model.model))
mapping = { 4: "left", 5: "right", 6: "both" };
if (["WXKG02LM_rev1", "WXKG02LM_rev2", "WXKG07LM"].includes(model.model))
mapping = { 1: "left", 2: "right", 3: "both" };
// Maybe other QKBG also support release/hold?
const actionLookup = ["QBKG03LM", "QBKG22LM", "QBKG04LM", "QBKG21LM"].includes(model.model)
? { 0: "hold", 1: "release", 2: "double" }
: { 0: "single", 1: "single" };
const action = actionLookup[msg.data.onOff];
const button = mapping?.[msg.endpoint.ID] ? `_${mapping[msg.endpoint.ID]}` : "";
if (action === "release") {
const anotherAction = globalStore.getValue(msg.endpoint, "hold", false) ? "hold_release" : "single";
publish({ action: `${anotherAction}${button}` });
}
globalStore.putValue(msg.endpoint, "hold", action === "hold");
return { action: `${action}${button}` };
},
},
lumi_action_multistate: {
cluster: "genMultistateInput",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model))
return;
// cubes
if (model.model === "MFKZQ01LM") {
/*
Source: https://github.com/kirovilya/ioBroker.zigbee
+---+
| 2 |
+---+---+---+
| 4 | 0 | 1 |
+---+---+---+
|M5I|
+---+
| 3 |
+---+
Side 5 is with the MI logo, side 3 contains the battery door.
presentValue = 0 = shake
presentValue = 2 = wakeup
presentValue = 3 = fly/fall
presentValue = y + x * 8 + 64 = 90º Flip from side x on top to side y on top
presentValue = x + 128 = 180º flip to side x on top
presentValue = x + 256 = push/slide cube while side x is on top
presentValue = x + 512 = double tap while side x is on top
*/
const value = msg.data.presentValue;
let result = null;
if (value === 0)
result = { action: "shake" };
else if (value === 1)
result = { action: "throw" };
else if (value === 2)
result = { action: "wakeup" };
else if (value === 3)
result = { action: "fall" };
else if (value >= 512)
result = { action: "tap", side: value - 512 };
else if (value >= 256)
result = { action: "slide", side: value - 256 };
else if (value >= 128)
result = { action: "flip180", side: value - 128 };
else if (value >= 64) {
result = {
action: "flip90",
action_from_side: Math.floor((value - 64) / 8),
action_to_side: value % 8,
action_side: value % 8,
from_side: Math.floor((value - 64) / 8),
to_side: value % 8,
side: value % 8,
};
}
return result ? result : null;
}
if (model.model === "CTP-R01") {
const value = msg.data.presentValue;
// biome-ignore lint/suspicious/noImplicitAnyLet: ignored using `--suppress`
let payload;
if (value === 0)
payload = { action: "shake" };
else if (value === 1)
payload = { action: "throw" };
else if (value === 2)
payload = { action: "1_min_inactivity" };
else if (value === 4)
payload = { action: "hold" };
else if (value >= 1024)
payload = { action: "flip_to_side", side: value - 1023 };
else if (value >= 512)
payload = { action: "tap", side: value - 511 };
else if (value >= 256)
payload = { action: "slide", side: value - 255 };
else if (value >= 128) {
payload = {
action: "flip180",
side: value - 127,
action_from_side: 7 - value + 127,
};
}
else if (value >= 64) {
payload = {
action: "flip90",
side: (value % 8) + 1,
action_from_side: Math.floor((value - 64) / 8) + 1,
};
}
else {
logger_1.logger.debug(`${model.model}: unknown action with value ${value}`, NS);
}
return payload;
}
let actionLookup = { 0: "hold", 1: "single", 2: "double", 3: "triple", 255: "release" };
// mini switches and opple
if (model.model === "WXKG12LM") {
actionLookup = { ...actionLookup, 16: "hold", 17: "release", 18: "shake" };
}
if (["WXKG13LM", "WXKG04LM", "WXCJKG11LM", "WXCJKG12LM", "WXCJKG13LM"].includes(model.model)) {
actionLookup = { ...actionLookup, 5: "quintuple", 6: "many" };
}
// wall switches
let buttonLookup = null;
if (["WXKG02LM_rev2", "WXKG07LM", "WXKG15LM", "WXKG17LM", "WXKG22LM"].includes(model.model)) {
buttonLookup = { 1: "left", 2: "right", 3: "both" };
}
if (["QBKG12LM", "QBKG24LM"].includes(model.model))
buttonLookup = { 5: "left", 6: "right", 7: "both" };
if (["QBKG39LM", "QBKG41LM", "WS-EUK02", "WS-EUK04", "QBKG18LM", "QBKG20LM", "QBKG28LM", "QBKG31LM", "ZNQBKG25LM"].includes(model.model)) {
buttonLookup = { 41: "left", 42: "right", 51: "both" };
}
if (["QBKG25LM", "QBKG26LM", "QBKG29LM", "QBKG32LM", "QBKG33LM", "QBKG34LM", "ZNQBKG31LM", "ZNQBKG26LM"].includes(model.model)) {
buttonLookup = {
41: "left",
42: "center",
43: "right",
51: "left_center",
52: "left_right",
53: "center_right",
61: "all",
};
}
// Z1 switches, ZNQBKG38LM only 1 button, so not add buttonLookup
if (["ZNQBKG39LM"].includes(model.model)) {
buttonLookup = { 1: "top", 2: "bottom" };
}
if (["ZNQBKG40LM"].includes(model.model)) {
buttonLookup = { 1: "top", 2: "center", 3: "bottom" };
}
if (["ZNQBKG41LM"].includes(model.model)) {
buttonLookup = { 1: "top", 2: "center", 3: "bottom", 4: "wireless" };
}
if (["WS-USC02", "WS-USC04"].includes(model.model)) {
buttonLookup = { 41: "top", 42: "bottom", 51: "both" };
}
const action = actionLookup[msg.data.presentValue];
if (["WXKG04LM", "WXCJKG11LM", "WXCJKG12LM", "WXCJKG13LM"].includes(model.model)) {
clearTimeout(globalStore.getValue(msg.endpoint, "timer"));
// 0 = hold
const button = msg.endpoint.ID;
if (msg.data.presentValue === 0) {
// Aqara Opple does not generate a release event when pressed for more than 5 seconds
// After 5 seconds of not releasing we assume release.
const timer = setTimeout(() => publish({ action: `button_${button}_release` }), 5000).unref();
globalStore.putValue(msg.endpoint, "timer", timer);
}
return { action: `button_${button}_${action}` };
}
if (buttonLookup) {
const button = buttonLookup[msg.endpoint.ID];
if (button) {
return { action: `${action}_${button}` };
}
}
else {
return { action };
}
},
},
lumi_action_analog: {
cluster: "genAnalogInput",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (model.model === "MFKZQ01LM") {
/*
Source: https://github.com/kirovilya/ioBroker.zigbee
presentValue = rotation angle left < 0, right > 0
*/
const value = msg.data.presentValue;
const result = {
action: value < 0 ? "rotate_left" : "rotate_right",
angle: Math.floor(value * 100) / 100,
action_angle: Math.floor(value * 100) / 100,
};
return result;
}
if (model.model === "CTP-R01") {
const value = msg.data.presentValue;
return {
action: value < 0 ? "rotate_left" : "rotate_right",
action_angle: Math.floor(value * 100) / 100,
};
}
},
},
lumi_temperature: {
cluster: "msTemperatureMeasurement",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const temperature = msg.data.measuredValue / 100.0;
// https://github.com/Koenkk/zigbee2mqtt/issues/798
// Sometimes the sensor publishes non-realistic vales.
if (temperature > -65 && temperature < 65) {
return { temperature };
}
},
},
lumi_pressure: {
cluster: "msPressureMeasurement",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const result = fz.pressure.convert(model, msg, publish, options, meta);
if (result && result.pressure > 500 && result.pressure < 2000) {
return result;
}
},
},
// lumi class specific
lumi_feeder: {
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const result = {};
Object.entries(msg.data).forEach(([key, value]) => {
switch (Number.parseInt(key, 10)) {
case 0xfff1: {
// @ts-expect-error ignore
if (value.length < 8) {
logger_1.logger.debug(`Cannot handle ${value}, frame too small`, "zhc:lumi:feeder");
return;
}
// @ts-expect-error ignore
const attr = value.slice(3, 7);
// @ts-expect-error ignore
const len = value.slice(7, 8).readUInt8();
// @ts-expect-error ignore
const val = value.slice(8, 8 + len);
switch (attr.readInt32BE()) {
case 0x04150055: // feeding
result.feed = "";
break;
case 0x041502bc: {
// feeding report
const report = val.toString();
result.feeding_source = { 0: "schedule", 1: "manual", 2: "remote" }[Number.parseInt(report.slice(0, 2), 10)];
result.feeding_size = Number.parseInt(report.slice(3, 4), 10);
break;
}
case 0x0d680055: // portions per day
result.portions_per_day = val.readUInt16BE();
break;
case 0x0d690055: // weight per day
result.weight_per_day = val.readUInt32BE();
break;
case 0x0d0b0055: // error ?
result.error = (0, utils_1.getFromLookup)(val.readUInt8(), { 1: true, 0: false });
break;
case 0x080008c8: {
// schedule string
const schlist = val.toString().split(",");
const schedule = [];
schlist.forEach((str) => {
// 7f13000100
if (str !== "//") {
const feedtime = node_buffer_1.Buffer.from(str, "hex");
schedule.push({
days: (0, utils_1.getFromLookup)(feedtime[0], feederDaysLookup),
hour: feedtime[1],
minute: feedtime[2],
size: feedtime[3],
});
}
});
result.schedule = schedule;
break;
}
case 0x04170055: // indicator
result.led_indicator = (0, utils_1.getFromLookup)(val.readUInt8(), { 1: "ON", 0: "OFF" });
break;
case 0x04160055: // child lock
result.child_lock = (0, utils_1.getFromLookup)(val.readUInt8(), { 1: "LOCK", 0: "UNLOCK" });
break;
case 0x04180055: // mode
result.mode = (0, utils_1.getFromLookup)(val.readUInt8(), { 1: "schedule", 0: "manual" });
break;
case 0x0e5c0055: // serving size
result.serving_size = val.readUInt8();
break;
case 0x0e5f0055: // portion weight
result.portion_weight = val.readUInt8();
break;
case 0x080007d1: // ? 64
case 0x0d090055: // ? 00
logger_1.logger.debug(`Unhandled attribute ${attr} = ${val}`, "zhc:lumi:feeder");
break;
default:
logger_1.logger.debug(`Unknown attribute ${attr} = ${val}`, "zhc:lumi:feeder");
}
break;
}
case 0x00ff: // 80:13:58:91:24:33:20:24:58:53:44:07:05:97:75:17
case 0x0007: // 00:00:00:00:1d:b5:a6:ed
case 0x00f7: // 05:21:14:00:0d:23:21:25:00:00:09:21:00:01
logger_1.logger.debug(`Unhandled key ${key} = ${value}`, "zhc:lumi:feeder");
break;
default:
logger_1.logger.debug(`Unknown key ${key} = ${value}`, "zhc:lumi:feeder");
}
});
return result;
},
},
lumi_trv: {
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const result = {};
Object.entries(msg.data).forEach(([key, value]) => {
switch (Number.parseInt(key, 10)) {
case 0x0271:
result.system_mode = (0, utils_1.getFromLookup)(value, { 1: "heat", 0: "off" });
break;
case 0x0272:
// @ts-expect-error ignore
Object.assign(result, exports.trv.decodePreset(value));
break;
case 0x0273:
result.window_detection = (0, utils_1.getFromLookup)(value, { 1: true, 0: false });
break;
case 0x0274:
result.valve_detection = (0, utils_1.getFromLookup)(value, { 1: true, 0: false });
break;
case 0x0277:
result.child_lock = (0, utils_1.getFromLookup)(value, { 1: "LOCK", 0: "UNLOCK" });
break;
case 0x0279:
(0, utils_1.assertNumber)(value);
result.away_preset_temperature = (value / 100).toFixed(1);
break;
case 0x027b:
result.calibrated = (0, utils_1.getFromLookup)(value, { 1: true, 0: false });
break;
case 0x027e:
result.sensor = (0, utils_1.getFromLookup)(value, { 1: "external", 0: "internal", 2: "external" });
break;
case 0x040a:
result.battery = value;
break;
case 0x027a:
result.window_open = (0, utils_1.getFromLookup)(value, { 1: true, 0: false });
break;
case 0x0275:
result.valve_alarm = (0, utils_1.getFromLookup)(value, { 1: true, 0: false, 4: true });
break;
case 247: {
// @ts-expect-error ignore
const heartbeat = exports.trv.decodeHeartbeat(meta, model, value);
logger_1.logger.debug(`${model.model}: Processed heartbeat message into payload ${JSON.stringify(heartbeat)}`, "zhc:lumi:trv");
if (heartbeat.firmware_version) {
// Overwrite the "placeholder" version `0.0.0_0025` advertised by `genBasic`
// with the correct version from the heartbeat.
// This is not reflected in the frontend unless the device is reconfigured
// or the whole service restarted.
// See https://github.com/Koenkk/zigbee-herdsman-converters/pull/5363#discussion_r1081477047
// @ts-expect-error ignore
meta.device.softwareBuildID = heartbeat.firmware_version;
delete heartbeat.firmware_version;
}
Object.assign(result, heartbeat);
break;
}
case 0x027d:
result.schedule = (0, utils_1.getFromLookup)(value, { 1: true, 0: false });
break;
case 0x0276: {
const buffer = value;
// Buffer is empty first message after pairing
// https://github.com/Koenkk/zigbee-herdsman-converters/issues/7128
if (buffer.length) {
const schedule = exports.trv.decodeSchedule(buffer);
result.schedule_settings = exports.trv.stringifySchedule(schedule);
}
break;
}
case 0x00ee: {
meta.device.meta.lumiFileVersion = value;
meta.device.save();
break;
}
case 0xfff2:
case 0x00ff: // 4e:27:49:bb:24:b6:30:dd:74:de:53:76:89:44:c4:81
case 0x027c: // 0x00
case 0x0280: // 0x00/0x01
logger_1.logger.debug(`Unhandled key ${key} = ${value}`, "zhc:lumi:trv");
break;
default:
logger_1.logger.debug(`Unknown key ${key} = ${value}`, "zhc:lumi:trv");
}
});
return result;
},
},
lumi_presence_region_events: {
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const payload = {};
Object.entries(msg.data).forEach(([key, value]) => {
const eventKey = Number.parseInt(key, 10);
switch (eventKey) {
case exports.presence.constants.region_event_key: {
if (!node_buffer_1.Buffer.isBuffer(value) ||
!(typeof value[0] === "string" || typeof value[0] === "number") ||
!(typeof value[1] === "string" || typeof value[1] === "number")) {
logger_1.logger.warning(`Action: Unrecognized payload structure '${JSON.stringify(value)}'`, NS);
break;
}
const [regionIdRaw, eventTypeCodeRaw] = value;
// @ts-expect-error ignore
const regionId = Number.parseInt(regionIdRaw, 10);
// @ts-expect-error ignore
const eventTypeCode = Number.parseInt(eventTypeCodeRaw, 10);
if (Number.isNaN(regionId)) {
logger_1.logger.warning(`Action: Invalid regionId "${regionIdRaw}"`, NS);
break;
}
if (!Object.values(exports.presence.constants.region_event_types).includes(eventTypeCode)) {
logger_1.logger.warning(`Action: Unknown region event type "${eventTypeCode}"`, NS);
break;
}
const eventTypeName = exports.presence.mappers.lumi_presence.region_event_type_names[eventTypeCode];
logger_1.logger.debug(`Action: Triggered event (region "${regionId}", type "${eventTypeName}")`, NS);
payload.action = `region_${regionId}_${eventTypeName}`;
break;
}
}
});
return payload;
},
},
lumi_lock_report: {
cluster: "genBasic",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data["65328"]) {
const data = `0x${msg.data["65328"].toString(16)}`;
const state = Number(data.substring(2, 4));
const action = Number(data.substring(4, 6));
const keynum = Number(data.substring(6, 8));
if (state === 11) {
if (action === 1) {
// unknown key
return { keyerror: true, inserted: "unknown" };
}
if (action === 3) {
// explicitly disabled key (i.e. reported lost)
return { keyerror: true, inserted: keynum };
}
if (action === 7) {
// strange object introduced into the cylinder (e.g. a lock pick)
return { keyerror: true, inserted: "strange" };
}
}
if (state === 12) {
if (action === 1) {
return { inserted: keynum };
}
if (action === 11) {
return { forgotten: keynum };
}
}
}
},
},
lumi_occupancy_illuminance: {
// This is for occupancy sensor that only send a message when motion detected,
// but do not send a motion stop.
// Therefore we need to publish the no_motion detected by ourselves.
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
options: [exposes.options.occupancy_timeout_2(), exposes.options.no_occupancy_since_true()],
convert: (model, msg, publish, options, meta) => {
if (msg.data.illuminance !== undefined) {
// The occupancy sensor only sends a message when motion detected.
// Therefore we need to publish the no_motion detected by ourselves.
let timeout = meta && meta.state && meta.state.detection_interval !== undefined
? Number(meta.state.detection_interval)
: ["RTCGQ14LM"].includes(model.model)
? 30
: 60;
timeout =
options?.occupancy_timeout != null && Number(options.occupancy_timeout) >= timeout
? Number(options.occupancy_timeout)
: timeout + 2;
// Stop existing timers because motion is detected and set a new one.
clearTimeout(globalStore.getValue(msg.endpoint, "occupancy_timer", null));
if (timeout !== 0) {
const timer = setTimeout(() => {
publish({ occupancy: false });
}, timeout * 1000).unref();
globalStore.putValue(msg.endpoint, "occupancy_timer", timer);
}
// Sometimes RTCGQ14LM reports high illuminance values in the dark
// https://github.com/Koenkk/zigbee2mqtt/issues/12596
const illuminance = msg.data.illuminance > 130536 ? 0 : msg.data.illuminance - 65536;
const payload = { occupancy: true, illuminance };
(0, utils_1.noOccupancySince)(msg.endpoint, options, publish, "start");
return payload;
}
},
},
lumi_curtain_position: {
cluster: "genAnalogOutput",
type: ["attributeReport", "readResponse"],
options: [exposes.options.invert_cover()],
convert: (model, msg, publish, options, meta) => {
if (model.model === "ZNCLDJ12LM" && msg.type === "attributeReport" && [0, 2].includes(msg.data.presentValue)) {
// Incorrect reports from the device, ignore (re-read by onEvent of ZNCLDJ12LM)
// https://github.com/Koenkk/zigbee-herdsman-converters/pull/1427#issuecomment-663862724
return;
}
let position = (0, utils_1.precisionRound)(msg.data.presentValue, 2);
position = options.invert_cover ? 100 - position : position;
const closed = options.invert_cover ? position === 100 : position === 0;
return { position, state: closed ? "CLOSE" : "OPEN" };
},
},
lumi_curtain_position_tilt: {
cluster: "closuresWindowCovering",
type: ["attributeReport", "readResponse"],
options: [exposes.options.invert_cover()],
convert: (model, msg, publish, options, meta) => {
const result = {};
const invert = model.meta?.coverInverted ? !options.invert_cover : options.invert_cover;
if (msg.data.currentPositionLiftPercentage !== undefined && msg.data.currentPositionLiftPercentage <= 100) {
const value = msg.data.currentPositionLiftPercentage;
const position = normalizeZNCLBL01LMTerminalPosition(invert ? 100 - value : value, model, msg.endpoint);
const state = invert ? (position > 0 ? "CLOSE" : "OPEN") : position > 0 ? "OPEN" : "CLOSE";
result[(0, utils_1.postfixWithEndpointName)("position", msg, model, meta)] = position;
result[(0, utils_1.postfixWithEndpointName)("state", msg, model, meta)] = state;
}
if (msg.data.currentPositionTiltPercentage !== undefined && msg.data.currentPositionTiltPercentage <= 100) {
const value = msg.data.currentPositionTiltPercentage;
result[(0, utils_1.postfixWithEndpointName)("tilt", msg, model, meta)] = invert ? 100 - value : value;
}
return result;
},
},
lumi_operation_mode_basic: {
cluster: "genBasic",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const payload = {};
if (model.meta && !model.meta.multiEndpoint) {
const mappingMode = { 18: "control_relay", 254: "decoupled" };
const key = 0xff22;
if (msg.data[key] !== undefined) {
payload.operation_mode = mappingMode[msg.data[key]];
}
}
else {
const mappingButton = { 65314: "left", 65315: "right" };
const mappingMode = { 18: "control_left_relay", 34: "control_right_relay", 254: "decoupled" };
for (const key in mappingButton) {
if (msg.data[key] !== undefined) {
payload[`operation_mode_${mappingButton[key]}`] = mappingMode[msg.data[key]];
}
}
}
return payload;
},
},
lumi_bulb_interval: {
cluster: "genBasic",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data["65281"]) {
const data = msg.data["65281"]; // Mi Struct
return {
state: data["100"] === 1 ? "ON" : "OFF",
brightness: data["101"],
color_temp: data["102"],
};
}
},
},
lumi_on_off: {
cluster: "genOnOff",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
// Lumi wall switches use endpoint 4, 5 or 6 to indicate an action on the button so we have to skip that.
if (msg.data.onOff !== undefined && ![4, 5, 6].includes(msg.endpoint.ID)) {
const property = (0, utils_1.postfixWithEndpointName)("state", msg, model, meta);
return { [property]: msg.data.onOff === 1 ? "ON" : "OFF" };
}
},
},
lumi_curtain_status: {
cluster: "genMultistateOutput",
type: ["attributeReport"],
convert: (model, msg, publish, options, meta) => {
let running = false;
const data = msg.data;
let lookup = {};
// For lumi.curtain.hagl04 and lumi.curtain.hagl07
if (["ZNCLDJ12LM", "ZNCLDJ14LM"].includes(model.model))
lookup = { 0: "closing", 1: "opening", 2: "stopped" };
// for lumi.curtain.acn002
if (["ZNJLBL01LM"].includes(model.model))
lookup = { 0: "closing", 1: "opening", 2: "stopped", 3: "blocked" };
if (data && data.presentValue !== undefined) {
const value = data.presentValue;
if (value < 2) {
running = true;
}
return {
motor_state: lookup[value],
running: running,
};
}
},
},
lumi_curtain_options: {
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data.curtainHandOpen !== undefined) {
return { hand_open: msg.data.curtainHandOpen === 0 };
}
if (msg.data.curtainReverse !== undefined) {
return { reverse_direction: msg.data.curtainReverse === 1 };
}
if (msg.data.curtainCalibrated !== undefined) {
return { limits_calibration: msg.data.curtainCalibrated === 1 ? "calibrated" : "recalibrate" };
}
},
},
lumi_vibration_analog: {
cluster: "closuresDoorLock",
type: ["attributeReport", "readResponse"],
options: [
exposes.options.vibration_timeout(),
exposes.options.calibration("x"),
exposes.options.calibration("y"),
exposes.options.calibration("z"),
],
convert: (model, msg, publish, options, meta) => {
const result = {};
if (msg.data["85"]) {
const vibrationLookup = { 1: "vibration", 2: "tilt", 3: "drop" };
result.action = vibrationLookup[msg.data["85"]];
// Device only sends a message when vibration is detected.
// Therefore we need to publish a no_vibration message on our own.
if (result.action === "vibration") {
result.vibration = true;
const timeout = options?.vibration_timeout != null ? Number(options.vibration_timeout) : 90;
// Stop any existing timer cause vibration detected
clearTimeout(globalStore.getValue(msg.endpoint, "vibration_timer", null));
globalStore.clearValue(msg.endpoint, "vibration_timer");
// Set new timer to publish no_vibration message
if (timeout !== 0) {
const timer = setTimeout(() => {
publish({ vibration: false });
}, timeout * 1000).unref();
globalStore.putValue(msg.endpoint, "vibration_timer", timer);
}
}
}
if (msg.data["1283"]) {
result.angle = msg.data["1283"];
}
if (msg.data["1285"]) {
// https://github.com/dresden-elektronik/deconz-rest-plugin/issues/748#issuecomment-419669995
// Only first 2 bytes are relevant.
const data = msg.data["1285"] >> 8;
// Swap byte order
result.strength = ((data & 0xff) << 8) | ((data >> 8) & 0xff);
}
if (msg.data["1288"]) {
const data = msg.data["1288"];
const buffer = node_buffer_1.Buffer.alloc(6);
buffer.writeUIntLE(data, 0, 6);
let x = buffer.readIntLE(0, 2);
let y = buffer.readIntLE(2, 2);
let z = buffer.readIntLE(4, 2);
// simple offset calibration
x = (0, utils_1.calibrateAndPrecisionRoundOptions)(x, options, "x");
y = (0, utils_1.calibrateAndPrecisionRoundOptions)(y, options, "y");
z = (0, utils_1.calibrateAndPrecisionRoundOptions)(z, options, "z");
// calibrated accelerometer values
result.x_axis = x;
result.y_axis = y;
result.z_axis = z;
// calculate angle
result.angle_x = Math.round((Math.atan(x / Math.sqrt(y * y + z * z)) * 180) / Math.PI);
result.angle_y = Math.round((Math.atan(y / Math.sqrt(x * x + z * z)) * 180) / Math.PI);
result.angle_z = Math.round((Math.atan(z / Math.sqrt(x * x + y * y)) * 180) / Math.PI);
// calculate absolute angle
const R = Math.sqrt(x * x + y * y + z * z);
result.angle_x_absolute = Math.round((Math.acos(x / R) * 180) / Math.PI);
result.angle_y_absolute = Math.round((Math.acos(y / R) * 180) / Math.PI);
}
return result;
},
},
lumi_illuminance: {
cluster: "msIlluminanceMeasurement",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
// also trigger movement, because there is no illuminance without movement
// https://github.com/Koenkk/zigbee-herdsman-converters/issues/1925
const newMsg = { ...msg, type: "attributeReport", data: { occupancy: 1 } };
const payload = fz.occupancy_with_timeout.convert(model, newMsg, publish, options, meta);
if (payload) {
const illuminance = msg.data.measuredValue;
payload.illuminance = illuminance;
}
return payload;
},
},
lumi_occupancy: {
// This is for occupancy sensor that only send a message when motion detected,
// but do not send a motion stop.
// Therefore we need to publish the no_motion detected by ourselves.
cluster: "msOccupancySensing",
type: ["attributeReport", "readResponse"],
options: [exposes.options.occupancy_timeout_2(), exposes.options.no_occupancy_since_true()],
convert: (model, msg, publish, options, meta) => {
if (msg.data.occupancy !== 1) {
// In case of 0 no occupancy is reported.
// https://github.com/Koenkk/zigbee2mqtt/issues/467
return;
}
// The occupancy sensor only sends a message when motion detected.
// Therefore we need to publish the no_motion detected by ourselves.
let timeout = meta && meta.state && meta.state.detection_interval !== undefined ? Number(meta.state.detection_interval) : 60;
timeout =
options?.occupancy_timeout != null && Number(options.occupancy_timeout) >= timeout ? Number(options.occupancy_timeout) : timeout + 2;
// Stop existing timers because motion is detected and set a new one.
clearTimeout(globalStore.getValue(msg.endpoint, "occupancy_timer", null));
if (timeout !== 0) {
const timer = setTimeout(() => {
publish({ occupancy: false });
}, timeout * 1000).unref();
globalStore.putValue(msg.endpoint, "occupancy_timer", timer);
}
const payload = { occupancy: true };
(0, utils_1.noOccupancySince)(msg.endpoint, options, publish, "start");
return payload;
},
},
lumi_smoke: {
cluster: "ssIasZone",
type: "commandStatusChangeNotification",
convert: (model, msg, publish, options, meta) => {
const newMsg = { ...msg, type: "commandStatusChangeNotification", data: { zonestatus: msg.data.zonestatus } };
const result = fz.ias_smoke_alarm_1.convert(model, newMsg, publish, options, meta);
const zoneStatus = msg.data.zonestatus;
if (result)
result.test = (zoneStatus & (1 << 1)) > 0;
return result;
},
},
lumi_gas_density: {
cluster: "genBasic",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const data = msg.data;
if (data?.["65281"]) {
const basicAttrs = data["65281"]; // Mi Struct
if (basicAttrs["100"] !== undefined) {
return { gas_density: basicAttrs["100"] };
}
}
},
},
lumi_gas_sensitivity: {
cluster: "ssIasZone",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const data = msg.data;
const lookup = { "1": "low", "2": "medium", "3": "high" };
if (data && data["65520"] !== undefined) {
const value = data["65520"];
if (value?.startsWith("0x020")) {
return {
sensitivity: lookup[value.charAt(5)],
};
}
}
},
},
lumi_door_lock_low_battery: {
cluster: "genPowerCfg",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data.batteryAlarmMask !== undefined) {
return { battery_low: msg.data.batteryAlarmMask === 1 };
}
},
},
lumi_door_lock_report: {
cluster: "closuresDoorLock",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const result = {};
const lockStatusLookup = {
1: "finger_not_match",
2: "password_not_match",
3: "reverse_lock", // disable open from outside
4: "reverse_lock_cancel", // enable open from outside
5: "locked",
6: "lock_opened",
7: "finger_add",
8: "finger_delete",
9: "password_add",
10: "password_delete",
11: "lock_opened_inside", // Open form inside reverse lock enable
12: "lock_opened_outside", // Open form outside reverse lock disable
13: "ring_bell",
14: "change_language_to",
15: "finger_open",
16: "password_open",
17: "door_closed",
};
if (model.model === "ZNMS11LM") {
if (msg.data["65296"]) {
// finger/password success
const data = msg.data["65296"].toString(16);
const command = data.substr(0, 1); // 1 finger open, 2 password open
const userId = data.substr(5, 2);
const userType = data.substr(1, 1); // 1 admin, 2 user
result.data = data;
result.action = `${lockStatusLookup[14 + Number.parseInt(command, 16)] + (userType === "1" ? "_admin" : "_user")}_id${Number.parseInt(userId, 16).toString()}`;
result.action_user = Number.parseInt(userId, 16);
}
else if (msg.data["65297"]) {
// finger, password failed or bell
const data = msg.data["65297"].toString(16);
const times = data.substr(0, 1);
const type = data.substr(5, 2); // 00 bell, 02 password, 40 error finger
result.data = data;
if (type === "40") {
result.action_action = lockStatusLookup[1];
result.action_repeat = Number.parseInt(times, 16);
}
else if (type === "02") {
result.action = lockStatusLookup[2];
result.action_repeat = Number.parseInt(times, 16);
}
else if (type === "00") {
result.action = lockStatusLookup[13];
}
}
else if (msg.data["65281"]?.["1"]) {
// user added/delete
const data = msg.data["65281"]["1"].toString(16);
const command = data.substr(0, 1); // 1 add, 2 delete
const userId = data.substr(5, 2);
result.action = lockStatusLookup[6 + Number.parseInt(command, 16)];
result.action_user = Number.parseInt(userId, 16);
}
}
if (["ZNMS12LM", "ZNMS13LM"].includes(model.model)) {
if (msg.data["65526"]) {
// lock final status
// Convert data back to hex to decode
const data = node_buffer_1.Buffer.from(msg.data["65526"], "ascii").toString("hex");
const command = data.substr(6, 4);
if (command === "0301" || // ZNMS12LM
command === "0341" // ZNMS13LM
) {
result.action = lockStatusLookup[4];
result.state = "UNLOCK";
result.reverse = "UNLOCK";
}
else if (command === "0311" || // ZNMS12LM
command === "0351" // ZNMS13LM
) {
result.action = lockStatusLookup[4];
result.state = "LOCK";
result.reverse = "UNLOCK";
}
else if (command === "0205" || // ZNMS12LM
command === "0245" // ZNMS13LM
) {
result.action = lockStatusLookup[3];
result.state = "UNLOCK";
result.reverse = "LOCK";
}
else if (command === "0215" || // ZNMS12LM
command === "0255" || // ZNMS13LM
command === "1355" // ZNMS13LM
) {
result.action = lockStatusLookup[3];
result.state = "LOCK";
result.reverse = "LOCK";
}
else if (command === "0111" || // ZNMS12LM
command === "1351" || // ZNMS13LM locked from inside
command === "1451" // ZNMS13LM locked from outside
) {
result.action = lockStatusLookup[5];
result.state = "LOCK";
result.reverse = "UNLOCK";
}
else if (command === "0b00" || // ZNMS12LM
command === "0640" || // ZNMS13LM
command === "0600" // ZNMS13LM
) {
result.action = lockStatusLookup[12];
result.state = "UNLOCK";
result.reverse = "UNLOCK";
}
else if (command === "0c00" || // ZNMS12LM
command === "2300" || // ZNMS13LM
command === "0540" || // ZNMS13LM
command === "0440" // ZNMS13LM
) {
result.action = lockStatusLookup[11];
result.state = "UNLOCK";
result.reverse = "UNLOCK";
}
else if (command === "2400" || // ZNMS13LM door closed from insed
command === "2401" // ZNMS13LM door closed from outside
) {
result.action = lockStatusLookup[17];
result.state = "UNLOCK";
result.reverse = "UNLOCK";
}
}
else if (msg.data["65296"]) {
// finger/password success
const data = node_buffer_1.Buffer.from(msg.data["65296"], "ascii").toString("hex");
const command = data.substr(6, 2); // 1 finger open, 2 password open
const userId = data.substr(12, 2);
const userType = data.substr(8, 1); // 1 admin, 2 user
result.action = `${lockStatusLookup[14 + Number.parseInt(command, 16)] + (userType === "1" ? "_admin" : "_user")}_id${Number.parseInt(userId, 16).toString()}`;
result.action_user = Number.parseInt(userId, 16);
}
else if (msg.data["65297"]) {
// finger, password failed or bell
const data = node_buffer_1.Buffer.from(msg.data["65297"], "ascii").toString("hex");
const times = data.substr(6, 2);
const type = data.substr(12, 2); // 00 bell, 02 password, 40 error finger
if (type === "40") {
result.action = lockStatusLookup[1];
result.action_repeat = Number.parseInt(times, 16);
}
else if (type === "00") {
result.action = lockStatusLookup[13];
result.action_repeat = null;
}
else if (type === "02") {
result.action = lockStatusLookup[2];
result.action_repeat = Number.parseInt(times, 16);
}
}
else if (msg.data["65281"]) {
// password added/delete
const data = node_buffer_1.Buffer.from(msg.data["65281"], "ascii").toString("hex");
const command = data.substr(18, 2); // 1 add, 2 delete
const userId = data.substr(12, 2);
result.action = lockStatusLookup[6 + Number.parseInt(command, 16)];
result.action_user = Number.parseInt(userId, 16);
}
else if (msg.data["65522"]) {
// set language
const data = node_buffer_1.Buffer.from(msg.data["65522"], "ascii").toString("hex");
const langId = data.substr(6, 2); // 1 chinese, 2: english
result.action = lockStatusLookup[14] + (langId === "2" ? "_english" : "_chinese");
}
}
return result;
},
},
lumi_action_on: {
cluster: "genOnOff",
type: "commandOn",
convert: (model, msg, publish, options, meta) => {
if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model))
return;
return { action: "button_2_single" };
},
},
lumi_action_off: {
cluster: "genOnOff",
type: "commandOff",
convert: (model, msg, publish, options, meta) => {
if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model))
return;
return { action: "button_1_single" };
},
},
lumi_action_step: {
cluster: "genLevelCtrl",
type: "commandStep",
convert: (model, msg, publish, options, meta) => {
if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model))
return;
const button = msg.data.stepmode === 0 ? "4" : "3";
return { action: `button_${button}_single` };
},
},
lumi_action_stop: {
cluster: "genLevelCtrl",
type: "commandStop",
convert: (model, msg, publish, options, meta) => {
if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model))
return;
if (globalStore.hasValue(msg.endpoint, "button")) {
const value = globalStore.getValue(msg.endpoint, "button");
const duration = Date.now() - value.start;
const payload = { action: `button_${value.button}_release`, action_duration: duration };
return payload;
}
},
},
lumi_action_move: {
cluster: "genLevelCtrl",
type: "commandMove",
convert: (model, msg, publish, options, meta) => {
if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model))
return;
const button = msg.data.movemode === 0 ? "4" : "3";
globalStore.putValue(msg.endpoint, "button", { button, start: Date.now() });
return { action: `button_${button}_hold` };
},
},
lumi_action_step_color_temp: {
cluster: "lightingColorCtrl",
type: "commandStepColorTemp",
convert: (model, msg, publish, options, meta) => {
if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model))
return;
// biome-ignore lint/suspicious/noImplicitAnyLet: ignored using `--suppress`
let action;
if (model.model === "WXCJKG12LM") {
// for WXCJKG12LM model it's double click event on buttons 3 and 4
action = msg.data.stepmode === 1 ? "3_double" : "4_double";
}
else {
// but for WXCJKG13LM model it's single click event on buttons 5 and 6
action = msg.data.stepmode === 1 ? "5_single" : "6_single";
}
return { action: `button_${action}` };
},
},
lumi_action_move_color_temp: {
cluster: "lightingColorCtrl",
type: "commandMoveColorTemp",
convert: (model, msg, publish, options, meta) => {
if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model))
return;
const stop = msg.data.movemode === 0;
let result = null;
if (stop) {
const button = globalStore.getValue(msg.endpoint, "button").button;
const duration = Date.now() - globalStore.getValue(msg.endpoint, "button").start;
result = { action: `button_${button}_release`, duration, action_duration: duration };
}
else {
const button = msg.data.movemode === 3 ? "6" : "5";
result = { action: `button_${button}_hold` };
globalStore.putValue(msg.endpoint, "button", { button, start: Date.now() });
}
return result;
},
},
// lumi device specific
// biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
lumi_action_WXKG01LM: {
// Unique converter
cluster: "genOnOff",
type: ["attributeReport", "readResponse"],
options: [
e
.numeric("hold_timeout", ea.SET)
.withValueMin(0)
.withDescription("The WXKG01LM only reports a button press and release." +
"By default, a hold action is published when there is at least 1000 ms between both events. It could be that due to " +
"delays in the network the release message is received late. This causes a single click to be identified as a hold " +
"action. If you are experiencing this you can try experimenting with this option (e.g. set it to 2000) (value is in ms)."),
e
.numeric("hold_timeout_expire", ea.SET)
.withValueMin(0)
.withDescription("Sometimes it happens that the button does not send a " +
"release. To avoid problems a release is automatically send after a timeout. The default timeout is 4000 ms, you can " +
"increase it with this option (value is in ms)."),
],
convert: (model, msg, publish, options, meta) => {
if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model))
return;
const state = msg.data.onOff;
// 0 = click down, 1 = click up, else = multiple clicks
if (state === 0) {
const timer = setTimeout(() => {
publish({ action: "hold" });
globalStore.clearValue(msg.endpoint, "timer");
globalStore.putValue(msg.endpoint, "hold", Date.now());
const holdTimer = setTimeout(() => {
globalStore.putValue(msg.endpoint, "hold", false);
}, options.hold_timeout_expire || 4000).unref();
globalStore.putValue(msg.endpoint, "hold_timer", holdTimer);
// After 4000 milliseconds of not receiving release we assume it will not happen.
}, options.hold_timeout || 1000).unref(); // After 1000 milliseconds of not releasing we assume hold.
globalStore.putValue(msg.endpoint, "timer", timer);
}
else if (state === 1) {
if (globalStore.getValue(msg.endpoint, "hold")) {
const duration = Date.now() - globalStore.getValue(msg.endpoint, "hold");
publish({ action: "release", duration: duration });
globalStore.putValue(msg.endpoint, "hold", false);
}
if (globalStore.getValue(msg.endpoint, "timer")) {
clearTimeout(globalStore.getValue(msg.endpoint, "timer"));
globalStore.clearValue(msg.endpoint, "timer");
publish({ action: "single" });
}
}
else {
const clicks = msg.data["32768"];
const actionLookup = { 1: "single", 2: "double", 3: "triple", 4: "quadruple" };
const payload = actionLookup[clicks] ? actionLookup[clicks] : "many";
publish({ action: payload });
}
},
},
// biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
lumi_smart_panel_ZNCJMB14LM: {
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const result = {};
if (msg.data[0x0215] !== undefined) {
const lookup = { 0: "classic", 1: "concise" };
result.theme = lookup[msg.data[0x0215]];
}
if (msg.data[0x0214] !== undefined) {
const lookup = { 1: "classic", 2: "analog clock" };
result.screen_saver_style = lookup[msg.data[0x0214]];
}
if (msg.data[0x0213] !== undefined) {
result.standby_enabled = !!(msg.data[0x0213] & 1);
}
if (msg.data[0x0212] !== undefined) {
const lookup = { 0: "mute", 1: "low", 2: "medium", 3: "high" };
result.beep_volume = lookup[msg.data[0x0212]];
}
if (msg.data[0x0211] !== undefined) {
result.lcd_brightness = msg.data[0x0211];
}
if (msg.data[0x022b] !== undefined) {
const lookup = { 0: "none", 1: "1", 2: "2", 3: "1 and 2", 4: "3", 5: "1 and 3", 6: "2 and 3", 7: "all" };
result.available_switches = lookup[msg.data[0x022b]];
}
if (msg.data[0x217] !== undefined) {
const lookup = { 3: "small", 4: "medium", 5: "large" };
result.font_size = lookup[msg.data[0x217]];
}
if (msg.data[0x219] !== undefined) {
const lookup = { 0: "scene", 1: "feel", 2: "thermostat", 3: "switch" };
result.homepage = lookup[msg.data[0x219]];
}
if (msg.data[0x210] !== undefined) {
const lookup = { 0: "chinese", 1: "english" };
result.language = lookup[msg.data[0x210]];
}
if (msg.data[0x216] !== undefined) {
result.standby_time = msg.data[0x216];
}
if (msg.data[0x218] !== undefined) {
result.lcd_auto_brightness_enabled = !!(msg.data[0x218] & 1);
}
if (msg.data[0x221] !== undefined) {
result.screen_saver_enabled = !!msg.data[0x221];
}
if (msg.data[0x222] !== undefined) {
result.standby_lcd_brightness = msg.data[0x222];
}
if (msg.data[0x223] !== undefined) {
const lookup = { 1: "1", 2: "2", 3: "3", 4: "4", 5: "5", 6: "6", 7: "7", 8: "8", 9: "9", 10: "10", 11: "11" };
const value = msg.data[0x223];
const textarr = value.slice(1);
result.switch_1_icon = lookup[value[0]];
result.switch_1_text = String.fromCharCode(...textarr);
}
if (msg.data[0x224] !== undefined) {
const lookup = { 1: "1", 2: "2", 3: "3", 4: "4", 5: "5", 6: "6", 7: "7", 8: "8", 9: "9", 10: "10", 11: "11" };
const value = msg.data[0x224];
const textarr = value.slice(1);
result.switch_2_icon = lookup[value[0]];
result.switch_2_text = String.fromCharCode(...textarr);
}
if (msg.data[0x225] !== undefined) {
const lookup = { 1: "1", 2: "2", 3: "3", 4: "4", 5: "5", 6: "6", 7: "7", 8: "8", 9: "9", 10: "10", 11: "11" };
const value = msg.data[0x225];
const textarr = value.slice(1);
result.switch_3_icon = lookup[value[0]];
result.switch_3_text = String.fromCharCode(...textarr);
}
return result;
},
},
w100_specific: {
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: async (model, msg, publish, options, meta) => {
const result = {};
if (msg.data[65522]) {
const base = w100EnsureDefaults(meta);
Object.assign(result, base);
const attr = msg.data[65522];
if (!attr || !node_buffer_1.Buffer.isBuffer(attr)) {
// defaults assigned
}
else {
const endsWith = node_buffer_1.Buffer.from([0x08, 0x00, 0x08, 0x44]);
const endsWithData = node_buffer_1.Buffer.from([0x08, 0x44]);
if (attr.length >= 4 && attr.slice(-4).equals(endsWith)) {
logger_1.logger.info(`Aqara W100: PMTSD request detected from device ${meta.device.ieeeAddr}`, NS);
let modeValue = 0;
if (base.system_mode === "off") {
modeValue = meta.device.meta.lastActiveMode ?? 0;
}
else {
const modeMap = { cool: 0, heat: 1, auto: 2 };
modeValue = modeMap[base.system_mode] ?? 0;
}
const convertToNumber = (key, value) => {
if (typeof value !== "string")
return value;
if (key === "fan_mode") {
const speedMap = { auto: 0, low: 1, medium: 2, high: 3 };
return speedMap[value.toLowerCase()] ?? 0;
}
if (key === "unused")
return Number.parseInt(value, 10);
return value;
};
const pmtsdValues = {
p: base.system_mode === "off" ? 1 : 0,
m: modeValue,
t: typeof base.occupied_heating_setpoint === "string"
? Number.parseFloat(base.occupied_heating_setpoint)
: base.occupied_heating_setpoint,
s: convertToNumber("fan_mode", base.fan_mode) ?? 0,
d: convertToNumber("unused", base.unused) ?? 0,
};
try {
const endpoint = meta.device.getEndpoint(1);
await w100SendPMTSD(endpoint, pmtsdValues, meta);
}
catch (error) {
logger_1.logger.error(`Aqara W100: Failed to send PMTSD frame: ${error.message}`, NS);
}
result.action = "W100_PMTSD_request";
}
else {
const idx = attr.indexOf(endsWithData);
if (idx !== -1 && idx + 2 < attr.length) {
const payloadLen = attr[idx + 2];
const payloadStart = idx + 3;
const payloadEnd = payloadStart + payloadLen;
if (payloadEnd <= attr.length) {
const payloadBytes = attr.slice(payloadStart, payloadEnd);
let payloadAscii;
try {
payloadAscii = payloadBytes.toString("ascii");
}
catch {
return;
}
const stateUpdate = {};
const partsForCombined = [];
const pairs = payloadAscii.split("_");
const pmtsd = {
p: base.system_mode === "off" ? 1 : 0,
// biome-ignore lint/suspicious/noExplicitAny: ignored using `--suppress`
m: meta.device.meta?.lastActiveMode ?? 0,
t: 15,
s: 0,
d: 0,
};
if (base.system_mode !== "off") {
const modeMap = { cool: 0, heat: 1, auto: 2 };
pmtsd.m = modeMap[base.system_mode] ?? 0;
}
pairs.forEach((pair) => {
if (pair.length >= 2) {
const key = pair[0].toLowerCase();
const value = pair.slice(1);
let newKey;
let stateKey;
let displayValue = value;
let processedValue = value;
switch (key) {
case "p":
newKey = "PW";
processedValue = Number.parseInt(value, 10);
pmtsd.p = processedValue;
break;
case "m":
newKey = "MW";
processedValue = Number.parseInt(value, 10);
pmtsd.m = processedValue;
break;
case "t":
newKey = "TW";
stateKey = "occupied_heating_setpoint";
processedValue = Number.parseFloat(value);
pmtsd.t = processedValue;
displayValue = processedValue;
break;
case "s":
newKey = "SW";
stateKey = "fan_mode";
processedValue = Number.parseInt(value, 10);
pmtsd.s = processedValue;
displayValue = ["auto", "low", "medium", "high"][processedValue];
break;
case "d":
newKey = "DW";
stateKey = "unused";
processedValue = Number.parseInt(value, 10);
pmtsd.d = processedValue;
displayValue = String(processedValue);
break;
default:
newKey = `${key.toUpperCase()}W`;
}
result[newKey] = value;
if (stateKey) {
stateUpdate[stateKey] = displayValue;
result[stateKey] = displayValue;
}
partsForCombined.push(`${newKey}${value}`);
}
});
const modeDisplay = ["cool", "heat", "auto"][pmtsd.m] || "cool";
const systemMode = pmtsd.p === 1 ? "off" : modeDisplay;
if (pmtsd.p === 0 && pmtsd.m !== undefined) {
if (!meta.device.meta)
meta.device.meta = {};
// biome-ignore lint/suspicious/noExplicitAny: ignored using `--suppress`
meta.device.meta.lastActiveMode = pmtsd.m;
}
stateUpdate.system_mode = systemMode;
result.system_mode = systemMode;
const date = new Date();
const pad = (n) => n.toString().padStart(2, "0");
const formattedDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
const combinedString = partsForCombined.length
? `${formattedDate}_${partsForCombined.join("_")}`
: `${formattedDate}`;
result.PMTSD_from_W100_Data = combinedString;
}
}
}
}
}
return result;
},
},
w100_temperature: {
cluster: "msTemperatureMeasurement",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
// W100 needs a custom converter to:
// 1. Expose both 'temperature' and 'local_temperature' (needed for climate)
// 2. Initialize device defaults (w100EnsureDefaults) needed for other converters
const result = fz.temperature.convert(model, msg, publish, options, meta);
if (result) {
const temperature = Object.values(result)[0];
const defaults = w100EnsureDefaults(meta);
return { temperature, local_temperature: temperature, ...defaults };
}
},
},
lumi_toilet: {
cluster: "manuSpecificLumi",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const result = {};
Object.entries(msg.data).forEach(([key, value]) => {
switch (Number.parseInt(key, 10)) {
case 0xfff1: {
// @ts-expect-error ignore
if (value.length < 8) {
logger_1.logger.debug(`Cannot handle ${value}, frame too small`, "zhc:lumi:toilet");
return;
}
// @ts-expect-error ignore
const attr = value.slice(3, 7);
// @ts-expect-error ignore
const len = value.slice(7, 8).readUInt8();
// @ts-expect-error ignore
const val = value.slice(8, 8 + len);
switch (attr.readInt32BE()) {
case 0x04030055:
result.lid_switch = val.readUInt8() === 1 ? "OPEN" : "CLOSE";
break;
case 0x04040055:
result.seat_switch = val.readUInt8() === 1 ? "OPEN" : "CLOSE";
break;
case 0x04200055:
result.night_light = val.readUInt8() === 1 ? "ON" : "OFF";
break;
case 0x0e2f0055:
result.seat_temp = ["Off", "Temp_31C", "Temp_33C", "Temp_35C", "Temp_37C", "Temp_39C"][val.readUInt32BE()];
break;
case 0x0e300055:
result.cleaning_mode = ["Stop", "Rear", "Rear_Moving", "Female", "Female_Moving", "Child"][val.readUInt32BE()];
break;
case 0x0e340055:
result.nozzle_position = ["Back", "Slightly_Back", "Middle", "Slightly_Front", "Front"][val.readUInt32BE()];
break;
case 0x0e330055:
result.water_pressure = ["Weak", "Slightly_Weak", "Middle", "Slightly_Strong", "Strong"][val.readUInt32BE()];
break;
case 0x0e320055:
result.water_temp = ["Off", "Temp_31C", "Temp_33C", "Temp_35C", "Temp_37C", "Temp_39C"][val.readUInt32BE()];
break;
case 0x0e350055:
result.dryer_temp = ["Off", "Normal", "Low", "Mid_Low", "Middle", "Mid_High", "High"][val.readUInt32BE()];
break;
case 0x0e270055:
result.nozzle_clean = ["Off", "Auto", "Manual"][val.readUInt32BE()];
break;
case 0x03010055:
result.occupancy_status = val.readUInt8() === 1;
break;
case 0x041a0055:
result.foot_sensor_switch = val.readUInt8() === 1 ? "ON" : "OFF";
break;
case 0x041f0055:
result.auto_flush_after_leave = val.readUInt8() === 0 ? "ON" : "OFF";
break;
case 0x04220055:
result.beeper_switch = val.readUInt8() === 0 ? "ON" : "OFF";
break;
case 0x04240055:
result.child_seat_mode = val.readUInt8() === 1 ? "ON" : "OFF";
break;
case 0x04250055:
result.pre_mist_switch = val.readUInt8() === 1 ? "ON" : "OFF";
break;
case 0x04420055:
result.auto_foam_on_sit = val.readUInt8() === 1 ? "ON" : "OFF";
break;
case 0x04430055:
result.auto_foam_on_leave = val.readUInt8() === 1 ? "ON" : "OFF";
break;
default:
logger_1.logger.debug(`Unknown attribute ${attr} = ${val}`, "zhc:lumi:toilet");
}
break;
}
case 0x00ff:
case 0x0007:
case 0x00f7:
logger_1.logger.debug(`Unhandled key ${key} = ${value}`, "zhc:lumi:toilet");
break;
default:
logger_1.logger.debug(`Unknown key ${key} = ${value}`, "zhc:lumi:toilet");
}
});
return result;
},
},
};
exports.toZigbee = {
// lumi generic
lumi_power: {
key: ["power"],
convertGet: async (entity, key, meta) => {
const endpoint = meta.device.endpoints.find((e) => e.supportsInputCluster("genAnalogInput")) ?? entity;
await endpoint.read("genAnalogInput", ["presentValue"]);
},
},
lumi_led_disabled_night: {
key: ["led_disabled_night"],
convertSet: async (entity, key, value, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if ([
"ZNCZ04LM",
"ZNCZ12LM",
"ZNCZ15LM",
"QBCZ14LM",
"QBCZ15LM",
"QBKG17LM",
"QBKG18LM",
"QBKG19LM",
"QBKG20LM",
"QBKG25LM",
"QBKG26LM",
"QBKG27LM",
"QBKG28LM",
"QBKG29LM",
"QBKG30LM",
"QBKG31LM",
"QBKG32LM",
"QBKG33LM",
"QBKG34LM",
"DLKZMK11LM",
"SSM-U01",
"WS-EUK01",
"WS-EUK02",
"WS-EUK03",
"WS-EUK04",
"SP-EUC01",
"ZNQBKG24LM",
"ZNQBKG25LM",
"ZNQBKG26LM",
"ZNQBKG38LM",
"ZNQBKG39LM",
"ZNQBKG40LM",
"ZNQBKG41LM",
].includes(meta.mapped.model)) {
await entity.write("manuSpecificLumi", { 515: { value: value ? 1 : 0, type: 0x10 } }, manufacturerOptions.lumi);
}
else if (["ZNCZ11LM"].includes(meta.mapped.model)) {
const payload = value
? [0xaa, 0x80, 0x05, 0xd1, 0x47, 0x00, 0x03, 0x10, 0x00]
: [0xaa, 0x80, 0x05, 0xd1, 0x47, 0x01, 0x03, 0x10, 0x01];
await entity.write("genBasic", { 65520: { value: payload, type: 0x41 } }, manufacturerOptions.lumi);
}
else {
throw new Error("Not supported");
}
return { state: { led_disabled_night: value } };
},
convertGet: async (entity, key, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if ([
"ZNCZ04LM",
"ZNCZ12LM",
"ZNCZ15LM",
"QBCZ15LM",
"QBCZ14LM",
"QBKG17LM",
"QBKG18LM",
"QBKG19LM",
"QBKG20LM",
"QBKG25LM",
"QBKG26LM",
"QBKG27LM",
"QBKG28LM",
"QBKG29LM",
"QBKG30LM",
"QBKG31LM",
"QBKG32LM",
"QBKG33LM",
"QBKG34LM",
"DLKZMK11LM",
"SSM-U01",
"WS-EUK01",
"WS-EUK02",
"WS-EUK03",
"WS-EUK04",
"SP-EUC01",
"ZNQBKG24LM",
"ZNQBKG25LM",
"ZNQBKG26LM",
"ZNQBKG38LM",
"ZNQBKG39LM",
"ZNQBKG40LM",
"ZNQBKG41LM",
].includes(meta.mapped.model)) {
await entity.read("manuSpecificLumi", [0x0203], manufacturerOptions.lumi);
}
else {
throw new Error("Not supported");
}
},
},
lumi_flip_indicator_light: {
key: ["flip_indicator_light"],
convertSet: async (entity, key, value, meta) => {
const lookup = { OFF: 0, ON: 1 };
await entity.write("manuSpecificLumi", { 240: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { flip_indicator_light: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x00f0], manufacturerOptions.lumi);
},
},
lumi_power_outage_count: {
key: ["power_outage_count"],
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0002], manufacturerOptions.lumi);
},
},
// lumi class specific
lumi_feeder: {
key: ["feed", "schedule", "led_indicator", "child_lock", "mode", "serving_size", "portion_weight"],
convertSet: async (entity, key, value, meta) => {
const sendAttr = async (attrCode, value, length) => {
// @ts-expect-error ignore
entity.sendSeq = ((entity.sendSeq || 0) + 1) % 256;
// @ts-expect-error ignore
const val = node_buffer_1.Buffer.from([0x00, 0x02, entity.sendSeq, 0, 0, 0, 0, 0]);
// @ts-expect-error ignore
entity.sendSeq += 1;
val.writeInt32BE(attrCode, 3);
val.writeUInt8(length, 7);
let v = node_buffer_1.Buffer.alloc(length);
switch (length) {
case 1:
v.writeUInt8(value);
break;
case 2:
v.writeUInt16BE(value);
break;
case 4:
v.writeUInt32BE(value);
break;
default:
// @ts-expect-error ignore
v = value;
}
await entity.write("manuSpecificLumi", { 65521: { value: node_buffer_1.Buffer.concat([val, v]), type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
};
switch (key) {
case "feed":
await sendAttr(0x04150055, 1, 1);
break;
case "schedule": {
const schedule = [];
// @ts-expect-error ignore
value.forEach((item) => {
const schedItem = node_buffer_1.Buffer.from([(0, utils_1.getKey)(feederDaysLookup, item.days, 0x7f), item.hour, item.minute, item.size, 0]);
schedule.push(schedItem.toString("hex"));
});
const val = node_buffer_1.Buffer.concat([node_buffer_1.Buffer.from(schedule.join(",")), node_buffer_1.Buffer.from([0])]);
// @ts-expect-error ignore
await sendAttr(0x080008c8, val, val.length);
break;
}
case "led_indicator":
await sendAttr(0x04170055, (0, utils_1.getFromLookup)(value, { ON: 1, OFF: 0 }), 1);
break;
case "child_lock":
await sendAttr(0x04160055, (0, utils_1.getFromLookup)(value, { UNLOCK: 0, LOCK: 1 }), 1);
break;
case "mode":
await sendAttr(0x04180055, (0, utils_1.getFromLookup)(value, { manual: 0, schedule: 1 }), 1);
break;
case "serving_size":
// @ts-expect-error ignore
await sendAttr(0x0e5c0055, value, 4);
break;
case "portion_weight":
// @ts-expect-error ignore
await sendAttr(0x0e5f0055, value, 4);
break;
default: // Unknown key
logger_1.logger.warning(`Unhandled key ${key}`, "zhc:lumi:feeder");
}
return { state: { [key]: value } };
},
},
lumi_detection_distance: {
key: ["detection_distance"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value, "detection_distance");
value = value.toLowerCase();
const lookup = { "10mm": 1, "20mm": 2, "30mm": 3 };
await entity.write("manuSpecificLumi", { 268: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, { manufacturerCode: exports.manufacturerCode });
return { state: { detection_distance: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x010c], { manufacturerCode: exports.manufacturerCode });
},
},
lumi_trv: {
key: [
"system_mode",
"preset",
"window_detection",
"valve_detection",
"child_lock",
"away_preset_temperature",
"calibrate",
"sensor",
"external_temperature_input",
"identify",
"schedule",
"schedule_settings",
],
convertSet: async (entity, key, value, meta) => {
const lumiHeader = (counter, params, action) => {
const header = [0xaa, 0x71, params.length + 3, 0x44, counter];
const integrity = 512 - header.reduce((sum, elem) => sum + elem, 0);
return [...header, integrity, action, 0x41, params.length];
};
const sensor = node_buffer_1.Buffer.from("00158d00019d1b98", "hex");
switch (key) {
case "system_mode":
await entity.write("manuSpecificLumi", { 625: { value: (0, utils_1.getFromLookup)(value, { off: 0, heat: 1 }), type: 0x20 } }, { manufacturerCode: exports.manufacturerCode });
break;
case "preset":
await entity.write("manuSpecificLumi", { 626: { value: (0, utils_1.getFromLookup)(value, { manual: 0, auto: 1, away: 2 }), type: 0x20 } }, { manufacturerCode: exports.manufacturerCode });
break;
case "window_detection":
await entity.write("manuSpecificLumi", {
627: { value: (0, utils_1.getFromLookup)(value, { false: 0, true: 1 }, undefined, true), type: 0x20 },
}, { manufacturerCode: exports.manufacturerCode });
break;
case "valve_detection":
await entity.write("manuSpecificLumi", {
628: { value: (0, utils_1.getFromLookup)(value, { false: 0, true: 1 }, undefined, true), type: 0x20 },
}, { manufacturerCode: exports.manufacturerCode });
break;
case "child_lock":
await entity.write("manuSpecificLumi", {
631: { value: (0, utils_1.getFromLookup)(value, { UNLOCK: 0, LOCK: 1 }), type: 0x20 },
}, { manufacturerCode: exports.manufacturerCode });
break;
case "away_preset_temperature":
await entity.write("manuSpecificLumi", {
633: { value: Math.round((0, utils_1.toNumber)(value, "away_preset_temperature") * 100), type: 0x23 },
}, { manufacturerCode: exports.manufacturerCode });
break;
case "sensor": {
(0, utils_1.assertEndpoint)(entity);
const device = node_buffer_1.Buffer.from(entity.deviceIeeeAddress.substring(2), "hex");
const timestamp = node_buffer_1.Buffer.alloc(4);
timestamp.writeUInt32BE(Date.now() / 1000);
if (value === "external") {
const params1 = [
...timestamp,
0x3d,
0x04,
...device,
...sensor,
0x00,
0x01,
0x00,
0x55,
0x13,
0x0a,
0x02,
0x00,
0x00,
0x64,
0x04,
0xce,
0xc2,
0xb6,
0xc8,
0x00,
0x00,
0x00,
0x00,
0x00,
0x01,
0x3d,
0x64,
0x65,
];
const params2 = [
...timestamp,
0x3d,
0x05,
...device,
...sensor,
0x08,
0x00,
0x07,
0xfd,
0x16,
0x0a,
0x02,
0x0a,
0xc9,
0xe8,
0xb1,
0xb8,
0xd4,
0xda,
0xcf,
0xdf,
0xc0,
0xeb,
0x00,
0x00,
0x00,
0x00,
0x00,
0x01,
0x3d,
0x04,
0x65,
];
const val1 = [...lumiHeader(0x12, params1, 0x02), ...params1];
const val2 = [...lumiHeader(0x13, params2, 0x02), ...params2];
await entity.write("manuSpecificLumi", { 65522: { value: val1, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
await entity.write("manuSpecificLumi", { 65522: { value: val2, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
}
else if (value === "internal") {
const params1 = [...timestamp, 0x3d, 0x05, ...device, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
const params2 = [...timestamp, 0x3d, 0x04, ...device, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
const val1 = [...lumiHeader(0x12, params1, 0x04), ...params1];
const val2 = [...lumiHeader(0x13, params2, 0x04), ...params2];
await entity.write("manuSpecificLumi", { 65522: { value: val1, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
await entity.write("manuSpecificLumi", { 65522: { value: val2, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
await entity.read("hvacThermostat", ["localTemp"]);
}
break;
}
case "external_temperature_input":
if (meta.state.sensor === "external") {
const temperatureBuf = node_buffer_1.Buffer.alloc(4);
const number = (0, utils_1.toNumber)(value);
temperatureBuf.writeFloatBE(Math.round(number * 100));
const params = [...sensor, 0x00, 0x01, 0x00, 0x55, ...temperatureBuf];
const data = [...lumiHeader(0x12, params, 0x05), ...params];
await entity.write("manuSpecificLumi", { 65522: { value: data, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
}
break;
case "calibrate":
await entity.write("manuSpecificLumi", { 624: { value: 1, type: 0x20 } }, { manufacturerCode: exports.manufacturerCode });
break;
case "identify":
await entity.command("genIdentify", "identify", { identifytime: 5 }, {});
break;
case "schedule":
await entity.write("manuSpecificLumi", {
637: { value: (0, utils_1.getFromLookup)(value, { false: 0, true: 1 }, undefined, true), type: 0x20 },
}, { manufacturerCode: exports.manufacturerCode });
break;
case "schedule_settings": {
// @ts-expect-error ignore
const schedule = exports.trv.parseSchedule(value);
exports.trv.validateSchedule(schedule);
const buffer = exports.trv.encodeSchedule(schedule);
await entity.write("manuSpecificLumi", { 630: { value: buffer, type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
break;
}
default: // Unknown key
logger_1.logger.debug(`Unhandled key ${key}`, "zhc:lumi:trv");
}
},
convertGet: async (entity, key, meta) => {
const dict = {
system_mode: 0x0271,
preset: 0x0272,
window_detection: 0x0273,
valve_detection: 0x0274,
child_lock: 0x0277,
away_preset_temperature: 0x0279,
calibrated: 0x027b,
sensor: 0x027e,
schedule: 0x027d,
schedule_settings: 0x0276,
};
await entity.read("manuSpecificLumi", [(0, utils_1.getFromLookup)(key, dict)], {
manufacturerCode: exports.manufacturerCode,
});
},
},
lumi_presence_region_upsert: {
key: ["region_upsert"],
convertSet: async (entity, key, value, meta) => {
const commandWrapper = exports.presence.parseAqaraFp1RegionUpsertInput(value);
if (!commandWrapper.isSuccess) {
logger_1.logger.warning(
// @ts-expect-error untyped
`Encountered an error (${commandWrapper.error.reason}) while parsing configuration commands (input: ${JSON.stringify(value)})`, NS);
return;
}
const command = commandWrapper.payload.command;
await writeAqaraFp1RegionUpsert(entity, { regionId: command.region_id, zones: command.zones });
return {
state: upsertConfiguredPresenceRegion(meta.state, {
regionId: command.region_id,
zones: command.zones,
}),
};
},
},
lumi_presence_region_delete: {
key: ["region_delete"],
convertSet: async (entity, key, value, meta) => {
const commandWrapper = exports.presence.parseAqaraFp1RegionDeleteInput(value);
if (!commandWrapper.isSuccess) {
logger_1.logger.warning(
// @ts-expect-error ignore
`Encountered an error (${commandWrapper.error.reason}) while parsing configuration commands (input: ${JSON.stringify(value)})`, NS);
return;
}
const command = commandWrapper.payload.command;
await writeAqaraFp1RegionDelete(entity, { regionId: command.region_id });
return {
state: deleteConfiguredPresenceRegion(meta.state, command.region_id),
};
},
},
lumi_cube_operation_mode: {
key: ["operation_mode"],
convertSet: (entity, key, value, meta) => {
const lookup = { action_mode: 0, scene_mode: 1 };
/**
* schedule the callback to run when the configuration window comes
*/
const callback = async () => {
await entity.write("manuSpecificLumi", { 328: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, { manufacturerCode: exports.manufacturerCode, disableDefaultResponse: true });
logger_1.logger.info("operation_mode switch success!", "zhc:lumi:cube");
};
globalStore.putValue(meta.device, "opModeSwitchTask", { callback, newMode: value });
logger_1.logger.info("Now give your cube a forceful throw motion (Careful not to drop it)!", "zhc:lumi:cube");
},
},
lumi_switch_operation_mode_basic: {
key: ["operation_mode"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertEndpoint)(entity);
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
let targetValue = (0, utils_1.isObject)(value) && value.state != null ? value.state : value;
// 1/2 gang switches using genBasic on endpoint 1.
// biome-ignore lint/suspicious/noImplicitAnyLet: ignored using `--suppress`
let attrId;
let attrValue;
if (meta.mapped.meta?.multiEndpoint) {
attrId = { left: 0xff22, right: 0xff23 }[meta.endpoint_name];
// Allow usage of control_relay for 2 gang switches by mapping it to the default side.
if (targetValue === "control_relay") {
targetValue = `control_${meta.endpoint_name}_relay`;
}
attrValue = (0, utils_1.getFromLookup)(targetValue, { control_left_relay: 0x12, control_right_relay: 0x22, decoupled: 0xfe });
if (attrId == null) {
throw new Error(`Unsupported endpoint ${meta.endpoint_name} for changing operation_mode.`);
}
}
else {
attrId = 0xff22;
attrValue = (0, utils_1.getFromLookup)(targetValue, { control_relay: 0x12, decoupled: 0xfe });
}
if (attrValue == null) {
throw new Error("Invalid operation_mode value");
}
const endpoint = entity.getDevice().getEndpoint(1);
const payload = {};
payload[attrId] = { value: attrValue, type: 0x20 };
await endpoint.write("genBasic", payload, manufacturerOptions.lumi);
return { state: { operation_mode: targetValue } };
},
convertGet: async (entity, key, meta) => {
// biome-ignore lint/suspicious/noImplicitAnyLet: ignored using `--suppress`
let attrId;
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if (meta.mapped.meta?.multiEndpoint) {
attrId = { left: 0xff22, right: 0xff23 }[meta.endpoint_name];
if (attrId == null) {
throw new Error(`Unsupported endpoint ${meta.endpoint_name} for getting operation_mode.`);
}
}
else {
attrId = 0xff22;
}
await entity.read("genBasic", [attrId], manufacturerOptions.lumi);
},
},
lumi_switch_operation_mode_opple: {
key: ["operation_mode"],
convertSet: async (entity, key, value, meta) => {
// Support existing syntax of a nested object just for the state field. Though it's quite silly IMO.
const targetValue = (0, utils_1.isObject)(value) && value.state != null ? value.state : value;
// Switches using manuSpecificLumi 0x0200 on the same endpoints as the onOff clusters.
const lookupState = { control_relay: 0x01, decoupled: 0x00 };
await entity.write("manuSpecificLumi", { 512: { value: (0, utils_1.getFromLookup)(targetValue, lookupState), type: 0x20 } }, manufacturerOptions.lumi);
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0200], manufacturerOptions.lumi);
},
},
lumi_detection_interval: {
key: ["detection_interval"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertNumber)(value, key);
value *= 1;
await entity.write("manuSpecificLumi", { 258: { value: [value], type: 0x20 } }, manufacturerOptions.lumi);
return { state: { detection_interval: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0102], manufacturerOptions.lumi);
},
},
lumi_overload_protection: {
key: ["overload_protection"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertNumber)(value, key);
value *= 1;
await entity.write("manuSpecificLumi", { 523: { value: [value], type: 0x39 } }, manufacturerOptions.lumi);
return { state: { overload_protection: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x020b], manufacturerOptions.lumi);
},
},
lumi_switch_mode_switch: {
key: ["mode_switch"],
convertSet: async (entity, key, value, meta) => {
const lookup = { anti_flicker_mode: 4, quick_mode: 1 };
await entity.write("manuSpecificLumi", { 4: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x21 } }, manufacturerOptions.lumi);
return { state: { mode_switch: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0004], manufacturerOptions.lumi);
},
},
lumi_button_switch_mode: {
key: ["button_switch_mode"],
convertSet: async (entity, key, value, meta) => {
const lookup = { relay: 0, relay_and_usb: 1 };
await entity.write("manuSpecificLumi", { 550: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { button_switch_mode: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0226], manufacturerOptions.lumi);
},
},
lumi_socket_button_lock: {
key: ["button_lock"],
convertSet: async (entity, key, value, meta) => {
const lookup = { ON: 0, OFF: 1 };
await entity.write("manuSpecificLumi", { 512: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { button_lock: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0200], manufacturerOptions.lumi);
},
},
lumi_dimmer_mode: {
key: ["dimmer_mode"],
convertSet: async (entity, key, value, meta) => {
const lookup = { rgbw: 3, dual_ct: 1 };
(0, utils_1.assertString)(value, key);
value = value.toLowerCase();
// @ts-expect-error ignore
if (["rgbw"].includes(value)) {
await entity.write("manuSpecificLumi", { 1289: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x23 } }, manufacturerOptions.lumi);
await entity.write("manuSpecificLumi", { 1295: { value: 1, type: 0x23 } }, manufacturerOptions.lumi);
}
else {
await entity.write("manuSpecificLumi", { 1289: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x23 } }, manufacturerOptions.lumi);
// Turn on dimming channel 1 and channel 2
await entity.write("manuSpecificLumi", { 1295: { value: 3, type: 0x23 } }, manufacturerOptions.lumi);
}
return { state: { dimmer_mode: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0509], manufacturerOptions.lumi);
},
},
lumi_switch_do_not_disturb: {
key: ["do_not_disturb"],
convertSet: async (entity, key, value, meta) => {
await entity.write("manuSpecificLumi", { 515: { value: value ? 1 : 0, type: 0x10 } }, manufacturerOptions.lumi);
return { state: { do_not_disturb: value } };
},
},
lumi_switch_type: {
key: ["switch_type"],
convertSet: async (entity, key, value, meta) => {
const lookup = { toggle: 1, momentary: 2 };
(0, utils_1.assertString)(value, key);
value = value.toLowerCase();
await entity.write("manuSpecificLumi", { 10: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { switch_type: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x000a], manufacturerOptions.lumi);
},
},
lumi_switch_power_outage_memory: {
key: ["power_outage_memory"],
convertSet: async (entity, key, value, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if ([
"SP-EUC01",
"ZNCZ04LM",
"ZNCZ15LM",
"QBCZ14LM",
"QBCZ15LM",
"SSM-U01",
"SSM-U02",
"DLKZMK11LM",
"DLKZMK12LM",
"WS-EUK01",
"WS-EUK02",
"WS-EUK03",
"WS-EUK04",
"QBKG17LM",
"QBKG18LM",
"QBKG19LM",
"QBKG20LM",
"QBKG25LM",
"QBKG26LM",
"QBKG27LM",
"QBKG28LM",
"QBKG29LM",
"QBKG30LM",
"QBKG31LM",
"QBKG32LM",
"QBKG33LM",
"QBKG34LM",
"QBKG38LM",
"QBKG39LM",
"QBKG40LM",
"QBKG41LM",
"ZNDDMK11LM",
"ZNLDP13LM",
"ZNQBKG31LM",
"WS-USC02",
"WS-USC03",
"WS-USC04",
"ZNQBKG24LM",
"ZNQBKG25LM",
"ZNQBKG26LM",
"JWDL001A",
"SSWQD02LM",
"SSWQD03LM",
"XDD11LM",
"XDD12LM",
"XDD13LM",
"ZNLDP12LM",
"ZNLDP13LM",
"ZNXDD01LM",
"WS-USC01",
].includes(meta.mapped.model)) {
await entity.write("manuSpecificLumi", { 513: { value: value ? 1 : 0, type: 0x10 } }, manufacturerOptions.lumi);
}
else if (["ZNCZ02LM", "QBCZ11LM", "LLKZMK11LM"].includes(meta.mapped.model)) {
const payload = value
? [
[0xaa, 0x80, 0x05, 0xd1, 0x47, 0x07, 0x01, 0x10, 0x01],
[0xaa, 0x80, 0x03, 0xd3, 0x07, 0x08, 0x01],
]
: [
[0xaa, 0x80, 0x05, 0xd1, 0x47, 0x09, 0x01, 0x10, 0x00],
[0xaa, 0x80, 0x03, 0xd3, 0x07, 0x0a, 0x01],
];
await entity.write("genBasic", { 65520: { value: payload[0], type: 0x41 } }, manufacturerOptions.lumi);
await entity.write("genBasic", { 65520: { value: payload[1], type: 0x41 } }, manufacturerOptions.lumi);
}
else if (["ZNCZ11LM", "ZNCZ12LM"].includes(meta.mapped.model)) {
const payload = value
? [0xaa, 0x80, 0x05, 0xd1, 0x47, 0x00, 0x01, 0x10, 0x01]
: [0xaa, 0x80, 0x05, 0xd1, 0x47, 0x01, 0x01, 0x10, 0x00];
await entity.write("genBasic", { 65520: { value: payload, type: 0x41 } }, manufacturerOptions.lumi);
}
else if (["ZNQBKG38LM", "ZNQBKG39LM", "ZNQBKG40LM", "ZNQBKG41LM"].includes(meta.mapped.model)) {
// Support existing syntax of a nested object just for the state field. Though it's quite silly IMO.
const targetValue = (0, utils_1.isObject)(value) && value.state != null ? value.state : value;
const lookupState = { on: 0x01, electric_appliances_on: 0x00, electric_appliances_off: 0x02, inverted: 0x03 };
await entity.write("manuSpecificLumi", { 1303: { value: (0, utils_1.getFromLookup)(targetValue, lookupState), type: 0x20 } }, manufacturerOptions.lumi);
}
else {
throw new Error("Not supported");
}
return { state: { power_outage_memory: value } };
},
convertGet: async (entity, key, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if ([
"SP-EUC01",
"ZNCZ04LM",
"ZNCZ15LM",
"QBCZ14LM",
"QBCZ15LM",
"SSM-U01",
"SSM-U02",
"DLKZMK11LM",
"DLKZMK12LM",
"WS-EUK01",
"WS-EUK02",
"WS-EUK03",
"WS-EUK04",
"QBKG17LM",
"QBKG18LM",
"QBKG19LM",
"QBKG20LM",
"QBKG25LM",
"QBKG26LM",
"QBKG27LM",
"QBKG28LM",
"QBKG29LM",
"QBKG30LM",
"QBKG31LM",
"QBKG32LM",
"QBKG33LM",
"QBKG34LM",
"QBKG38LM",
"QBKG39LM",
"QBKG40LM",
"QBKG41LM",
"ZNDDMK11LM",
"ZNLDP13LM",
"ZNQBKG31LM",
"WS-USC02",
"WS-USC03",
"WS-USC04",
"ZNQBKG24LM",
"ZNQBKG25LM",
"ZNQBKG26LM",
"JWDL001A",
"SSWQD02LM",
"SSWQD03LM",
"XDD11LM",
"XDD12LM",
"XDD13LM",
"ZNLDP12LM",
"ZNLDP13LM",
"ZNXDD01LM",
"WS-USC01",
].includes(meta.mapped.model)) {
await entity.read("manuSpecificLumi", [0x0201]);
}
else if (["ZNCZ02LM", "QBCZ11LM", "ZNCZ11LM", "ZNCZ12LM"].includes(meta.mapped.model)) {
await entity.read("manuSpecificLumi", [0xfff0]);
}
else if (["ZNQBKG38LM", "ZNQBKG39LM", "ZNQBKG40LM", "ZNQBKG41LM"].includes(meta.mapped.model)) {
await entity.read("manuSpecificLumi", [0x0517]);
}
else {
throw new Error("Not supported");
}
},
},
lumi_light_power_outage_memory: {
key: ["power_outage_memory"],
convertSet: async (entity, key, value, meta) => {
await entity.write("genBasic", { 65305: { value: value ? 1 : 0, type: 0x10 } }, manufacturerOptions.lumi);
return { state: { power_outage_memory: value } };
},
},
lumi_auto_off: {
key: ["auto_off"],
convertSet: async (entity, key, value, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if (["ZNCZ04LM", "ZNCZ12LM", "SP-EUC01"].includes(meta.mapped.model)) {
await entity.write("manuSpecificLumi", { 514: { value: value ? 1 : 0, type: 0x10 } }, manufacturerOptions.lumi);
}
else if (["ZNCZ11LM"].includes(meta.mapped.model)) {
const payload = value
? [0xaa, 0x80, 0x05, 0xd1, 0x47, 0x00, 0x02, 0x10, 0x01]
: [0xaa, 0x80, 0x05, 0xd1, 0x47, 0x01, 0x02, 0x10, 0x00];
await entity.write("genBasic", { 65520: { value: payload, type: 0x41 } }, manufacturerOptions.lumi);
}
else {
throw new Error("Not supported");
}
return { state: { auto_off: value } };
},
convertGet: async (entity, key, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if (["ZNCZ04LM", "ZNCZ12LM", "SP-EUC01"].includes(meta.mapped.model)) {
await entity.read("manuSpecificLumi", [0x0202], manufacturerOptions.lumi);
}
else {
throw new Error("Not supported");
}
},
},
lumi_detection_period: {
key: ["detection_period"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertNumber)(value, key);
value *= 1;
await entity.write("manuSpecificLumi", { 0: { value: [value], type: 0x21 } }, manufacturerOptions.lumi);
return { state: { detection_period: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0000], manufacturerOptions.lumi);
},
},
lumi_motion_sensitivity: {
key: ["motion_sensitivity"],
convertSet: async (entity, key, value, meta) => {
const lookup = { low: 1, medium: 2, high: 3 };
(0, utils_1.assertString)(value, key);
value = value.toLowerCase();
await entity.write("manuSpecificLumi", { 268: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { motion_sensitivity: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x010c], manufacturerOptions.lumi);
},
},
lumi_presence: {
key: ["presence"],
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0142], manufacturerOptions.lumi);
},
},
lumi_monitoring_mode: {
key: ["monitoring_mode"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value, key);
value = value.toLowerCase();
const lookup = { undirected: 0, left_right: 1 };
await entity.write("manuSpecificLumi", { 324: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { monitoring_mode: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0144], manufacturerOptions.lumi);
},
},
lumi_approach_distance: {
key: ["approach_distance"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value, key);
value = value.toLowerCase();
const lookup = { far: 0, medium: 1, near: 2 };
await entity.write("manuSpecificLumi", { 326: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { approach_distance: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0146], manufacturerOptions.lumi);
},
},
lumi_reset_nopresence_status: {
key: ["reset_nopresence_status"],
convertSet: async (entity, key, value, meta) => {
await entity.write("manuSpecificLumi", { 343: { value: 1, type: 0x20 } }, manufacturerOptions.lumi);
},
},
lumi_switch_click_mode: {
key: ["click_mode"],
convertSet: async (entity, key, value, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if (["ZNQBKG38LM", "ZNQBKG39LM", "ZNQBKG40LM", "ZNQBKG41LM"].includes(meta.mapped.model)) {
await entity.write("manuSpecificLumi", { 646: { value: (0, utils_1.getFromLookup)(value, { fast: 0x1, multi: 0x02 }), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { click_mode: value } };
}
await entity.write("manuSpecificLumi", { 293: { value: (0, utils_1.getFromLookup)(value, { fast: 0x1, multi: 0x02 }), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { click_mode: value } };
},
convertGet: async (entity, key, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if (["ZNQBKG38LM", "ZNQBKG39LM", "ZNQBKG40LM", "ZNQBKG41LM"].includes(meta.mapped.model)) {
await entity.read("manuSpecificLumi", [0x0286], manufacturerOptions.lumi);
}
else {
await entity.read("manuSpecificLumi", [0x125], manufacturerOptions.lumi);
}
},
},
lumi_switch_lock_relay_opple: {
key: ["lock_relay"],
convertSet: async (entity, key, value, meta) => {
await entity.write("manuSpecificLumi", { 645: { value: value ? 1 : 0, type: 0x20 } }, manufacturerOptions.lumi);
return { state: { lock_relay: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0285], manufacturerOptions.lumi);
},
},
lumi_operation_mode_opple: {
key: ["operation_mode"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value);
// modes:
// 0 - 'command' mode. keys send commands. useful for binding
// 1 - 'event' mode. keys send events. useful for handling
const lookup = { command: 0, event: 1 };
const endpoint = meta.device.getEndpoint(1);
await endpoint.write("manuSpecificLumi", { mode: (0, utils_1.getFromLookup)(value.toLowerCase(), lookup) }, { manufacturerCode: manufacturerOptions.lumi.manufacturerCode });
return { state: { operation_mode: value.toLowerCase() } };
},
convertGet: async (entity, key, meta) => {
const endpoint = meta.device.getEndpoint(1);
await endpoint.read("manuSpecificLumi", ["mode"], {
manufacturerCode: manufacturerOptions.lumi.manufacturerCode,
});
},
},
lumi_vibration_sensitivity: {
key: ["sensitivity"],
convertSet: async (entity, key, value, meta) => {
if ((0, utils_1.isString)(value)) {
value = (0, utils_1.getFromLookup)(value, { low: 0x15, medium: 0x0b, high: 0x01 });
}
(0, utils_1.assertNumber)(value);
const options = { ...manufacturerOptions.lumi, timeout: 35000 };
await entity.write("genBasic", { 65293: { value, type: 0x20 } }, options);
return { state: { sensitivity: value } };
},
},
lumi_interlock: {
key: ["interlock"],
convertSet: async (entity, key, value, meta) => {
await entity.write("genBinaryOutput", { 65286: { value: value ? 0x01 : 0x00, type: 0x10 } }, manufacturerOptions.lumi);
return { state: { interlock: value } };
},
},
lumi_curtain_options: {
key: ["options"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertObject)(value);
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
const opts = {
reverse_direction: false,
hand_open: true,
reset_limits: false,
...value,
};
// Legacy names
if (value.auto_close != null)
opts.hand_open = value.auto_close;
if (value.reset_move != null)
opts.reset_limits = value.reset_move;
if (meta.mapped.model === "ZNCLDJ12LM") {
await entity.write("genBasic", { 65320: { value: opts.reverse_direction, type: 0x10 } }, manufacturerOptions.lumi);
await entity.write("genBasic", { 65321: { value: !opts.hand_open, type: 0x10 } }, manufacturerOptions.lumi);
if (opts.reset_limits) {
await entity.write("genBasic", { 65319: { value: 0x00, type: 0x10 } }, manufacturerOptions.lumi);
}
}
else if (meta.mapped.model === "ZNCLDJ11LM") {
const payload = [
0x07,
0x00,
opts.reset_limits ? 0x01 : 0x02,
0x00,
opts.reverse_direction ? 0x01 : 0x00,
0x04,
!opts.hand_open ? 0x01 : 0x00,
0x12,
];
await entity.write("genBasic", { 1025: { value: payload, type: 0x42 } }, manufacturerOptions.lumi);
// hand_open requires a separate request with slightly different payload
payload[2] = 0x08;
await entity.write("genBasic", { 1025: { value: payload, type: 0x42 } }, manufacturerOptions.lumi);
}
else {
throw new Error(`lumi_curtain_options set called for not supported model: ${meta.mapped.model}`);
}
// Reset limits is an action, not a state.
delete opts.reset_limits;
return { state: { options: opts } };
},
convertGet: async (entity, key, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if (meta.mapped.model === "ZNCLDJ11LM") {
await entity.read("genBasic", [0x0401], manufacturerOptions.lumi);
}
else {
throw new Error(`lumi_curtain_options get called for not supported model: ${meta.mapped.model}`);
}
},
},
lumi_curtain_position_state: {
key: ["state", "position"],
options: [exposes.options.invert_cover()],
convertSet: async (entity, key, value, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if (key === "state" && typeof value === "string" && value.toLowerCase() === "stop") {
if (["ZNJLBL01LM", "ZNCLDJ14LM"].includes(meta.mapped.model)) {
const payload = { presentValue: 2 };
await entity.write("genMultistateOutput", payload);
}
else {
await entity.command("closuresWindowCovering", "stop", {}, (0, utils_1.getOptions)(meta.mapped, entity));
}
if (!["ZNCLDJ11LM", "ZNCLBL01LM"].includes(meta.mapped.model)) {
// The code below is originally added for ZNCLDJ11LM (Koenkk/zigbee2mqtt#4585).
// However, in Koenkk/zigbee-herdsman-converters#4039 it was replaced by reading
// directly from currentPositionLiftPercentage, so that device is excluded.
// For ZNJLBL01LM, in Koenkk/zigbee-herdsman-converters#4163 the position is read
// through onEvent each time the motor stops, so it becomes redundant, and the
// device is excluded.
// The code is left here to avoid breaking compatibility, ideally all devices using
// this converter should be tested so the code can be adjusted/deleted.
// Lumi curtain does not send position update on stop, request this.
await entity.read("genAnalogOutput", [0x0055]);
}
}
else {
const lookup = { open: 100, close: 0, on: 100, off: 0 };
value = typeof value === "string" ? value.toLowerCase() : value;
if ((0, utils_1.isString)(value)) {
value = (0, utils_1.getFromLookup)(value, lookup);
}
(0, utils_1.assertNumber)(value);
const targetPosition = value;
if (["ZNCLBL01LM"].includes(meta.mapped.model)) {
rememberZNCLBL01LMTerminalTargetPosition(targetPosition, meta.mapped, entity, true);
}
value = meta.options.invert_cover ? 100 - value : value;
if (["ZNCLBL01LM"].includes(meta.mapped.model)) {
await entity.command("closuresWindowCovering", "goToLiftPercentage", { percentageliftvalue: value }, (0, utils_1.getOptions)(meta.mapped, entity));
}
else {
const payload = { presentValue: value };
await entity.write("genAnalogOutput", payload);
}
return { state: { position: value } };
}
},
convertGet: async (entity, key, meta) => {
if (!Array.isArray(meta.mapped) && "ZNJLBL01LM" === meta.mapped.model) {
// https://github.com/Koenkk/zigbee2mqtt/issues/23056
logger_1.logger.debug(`Not reading position of ZNJLBL01LM since device doesn't support it`, NS);
}
else if (!Array.isArray(meta.mapped) && ["ZNCLBL01LM"].includes(meta.mapped.model)) {
await entity.read("closuresWindowCovering", ["currentPositionLiftPercentage"]);
}
else {
await entity.read("genAnalogOutput", [0x0055]);
}
},
},
lumi_curtain_battery_voltage: {
key: ["voltage"],
convertGet: async (entity, key, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
switch (meta.mapped.model) {
case "ZNCLBL01LM":
await entity.read("manuSpecificLumi", [0x040b], manufacturerOptions.lumi);
break;
default:
throw new Error(`lumi_curtain_battery_voltage - unsupported model: ${meta.mapped.model}`);
}
},
},
lumi_curtain_charging_status: {
key: ["charging_status"],
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0409], manufacturerOptions.lumi);
},
},
lumi_curtain_battery: {
key: ["battery"],
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x040a], manufacturerOptions.lumi);
},
},
lumi_trigger_indicator: {
key: ["trigger_indicator"],
convertSet: async (entity, key, value, meta) => {
await entity.write("manuSpecificLumi", { 338: { value: value ? 1 : 0, type: 0x20 } }, manufacturerOptions.lumi);
return { state: { trigger_indicator: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0152], manufacturerOptions.lumi);
},
},
lumi_curtain_hooks_lock: {
key: ["hooks_lock"],
convertSet: async (entity, key, value, meta) => {
const lookup = { UNLOCK: 0, LOCK: 1 };
await entity.write("manuSpecificLumi", { 1063: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { [key]: value } };
},
},
lumi_curtain_hooks_state: {
key: ["hooks_state"],
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0428], manufacturerOptions.lumi);
},
},
lumi_curtain_hand_open: {
key: ["hand_open"],
convertSet: async (entity, key, value, meta) => {
await entity.write("manuSpecificLumi", { curtainHandOpen: value ? 0 : 1 }, manufacturerOptions.lumi);
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", ["curtainHandOpen"], manufacturerOptions.lumi);
},
},
lumi_curtain_reverse: {
key: ["reverse_direction"],
convertSet: async (entity, key, value, meta) => {
await entity.write("manuSpecificLumi", { curtainReverse: value ? 1 : 0 }, manufacturerOptions.lumi);
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", ["curtainReverse"], manufacturerOptions.lumi);
},
},
lumi_curtain_limits_calibration: {
key: ["limits_calibration"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value);
const normalizedValue = value.toLowerCase();
switch (normalizedValue) {
case "start":
await entity.write("manuSpecificLumi", { 1031: { value: 0x01, type: 0x20 } }, manufacturerOptions.lumi);
break;
case "end":
await entity.write("manuSpecificLumi", { 1031: { value: 0x02, type: 0x20 } }, manufacturerOptions.lumi);
break;
case "reset":
await entity.write("manuSpecificLumi", { 1031: { value: 0x00, type: 0x20 } }, manufacturerOptions.lumi);
// also? await entity.write('manuSpecificLumi', {0x0402: {value: 0x00, type: 0x10}}, manufacturerOptions.lumi);
break;
}
},
},
// biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
lumi_curtain_limits_calibration_ZNCLDJ14LM: {
key: ["limits_calibration"],
options: [
e.enum("limits_calibration", ea.ALL, ["calibrated", "recalibrate", "open", "close"]).withDescription("Recalibrate the position limits"),
],
convertSet: async (entity, key, value, meta) => {
switch (value) {
case "recalibrate":
await entity.write("manuSpecificLumi", { curtainCalibrated: 0 }, manufacturerOptions.lumi);
break;
case "open":
await entity.write("genMultistateOutput", { presentValue: 1 }, manufacturerOptions.lumi);
break;
case "close":
await entity.write("genMultistateOutput", { presentValue: 0 }, manufacturerOptions.lumi);
break;
}
},
},
// biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
lumi_curtain_automatic_calibration_ZNCLDJ01LM: {
key: ["automatic_calibration"],
convertSet: async (entity, key, value, meta) => {
// Check if the curtain is already calibrated
const checkIfCalibrated = async () => {
const result = await entity.read("manuSpecificLumi", ["curtainCalibrated"]);
return result ? !!result.curtainCalibrated : false;
};
if (await checkIfCalibrated()) {
logger_1.logger.info("End positions already calibrated. Reset the calibration before proceeding.", NS);
return;
}
// Reset Calibration
await entity.write("manuSpecificLumi", { 1031: { value: 0x00, type: 0x20 } }, manufacturerOptions.lumi);
logger_1.logger.info("Starting the calibration process...", NS);
// Wait for 3 seconds
await (0, utils_1.sleep)(3000);
// Move the curtain to one direction
await entity.command("closuresWindowCovering", "goToLiftPercentage", { percentageliftvalue: 100 }, (0, utils_1.getOptions)(meta.mapped, entity));
logger_1.logger.info("Moving curtain and waiting to reach the end position.", NS);
// Wait until the curtain gets into a moving state, then wait until it gets blocked or stopped
const waitForStateTransition = async (initialStates, desiredStates) => {
return await new Promise((resolve) => {
const checkState = async () => {
const result = await entity.read("manuSpecificLumi", [0x0421]);
if (result && !initialStates.includes(result[0x0421])) {
const checkDesiredState = async () => {
const result2 = await entity.read("manuSpecificLumi", [0x0421]);
if (result2 && desiredStates.includes(result2[0x0421])) {
resolve();
}
else {
setTimeout(checkDesiredState, 500).unref();
}
};
setTimeout(checkDesiredState, 500).unref();
}
else {
setTimeout(checkState, 500).unref();
}
};
void checkState();
});
};
await waitForStateTransition([2, 3], [2, 3]);
// Wait for 1 second
await (0, utils_1.sleep)(1000);
// Set First Calibration Position
await entity.write("manuSpecificLumi", { 1031: { value: 0x01, type: 0x20 } }, manufacturerOptions.lumi);
logger_1.logger.info("End position 1 has been set.", NS);
// Wait for 3 seconds
await (0, utils_1.sleep)(3000);
// Move the curtain in the opposite direction
await entity.command("closuresWindowCovering", "goToLiftPercentage", { percentageliftvalue: 0 }, (0, utils_1.getOptions)(meta.mapped, entity));
logger_1.logger.info("Moving curtain in the opposite direction and waiting to reach the end position.", NS);
// Wait until the curtain gets into a moving state, then wait until it gets blocked or stopped
await waitForStateTransition([2, 3], [2, 3]);
// Wait for 1 second
await (0, utils_1.sleep)(1000);
// Set Second Calibration Position
await entity.write("manuSpecificLumi", { 1031: { value: 0x02, type: 0x20 } }, manufacturerOptions.lumi);
logger_1.logger.info("End position 2 has been set.", NS);
logger_1.logger.info("Calibration process completed.", NS);
},
},
lumi_buzzer: {
key: ["buzzer"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value, key);
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
const attribute = ["JY-GZ-01AQ"].includes(meta.mapped.model) ? 0x013e : 0x013f;
value = value.toLowerCase() === "alarm" ? 15361 : 15360;
await entity.write("manuSpecificLumi", { [`${attribute}`]: { value: [`${value}`], type: 0x23 } }, manufacturerOptions.lumi);
value = value === 15361 ? 0 : 1;
await entity.write("manuSpecificLumi", { 294: { value: [`${value}`], type: 0x20 } }, manufacturerOptions.lumi);
},
},
lumi_buzzer_manual: {
key: ["buzzer_manual_alarm", "buzzer_manual_mute"],
convertGet: async (entity, key, meta) => {
if (key === "buzzer_manual_mute") {
await entity.read("manuSpecificLumi", [0x0126], manufacturerOptions.lumi);
}
else if (key === "buzzer_manual_alarm") {
await entity.read("manuSpecificLumi", [0x013d], manufacturerOptions.lumi);
}
},
},
lumi_heartbeat_indicator: {
key: ["heartbeat_indicator"],
convertSet: async (entity, key, value, meta) => {
await entity.write("manuSpecificLumi", { 316: { value: value ? 1 : 0, type: 0x20 } }, manufacturerOptions.lumi);
return { state: { heartbeat_indicator: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x013c], manufacturerOptions.lumi);
},
},
lumi_selftest: {
key: ["selftest"],
convertSet: async (entity, key, value, meta) => {
if (Array.isArray(meta.mapped))
throw new Error("Not supported for groups");
if (["JTYJ-GD-01LM/BW", "JTQJ-BF-01LM/BW"].includes(meta.mapped.model)) {
// Timeout of 30 seconds + required (https://github.com/Koenkk/zigbee2mqtt/issues/2287)
const options = { ...manufacturerOptions.lumi, timeout: 35000 };
await entity.write("ssIasZone", { 65521: { value: 0x03010000, type: 0x23 } }, options);
}
else {
await entity.write("manuSpecificLumi", { 295: { value: true, type: 0x10 } }, manufacturerOptions.lumi);
}
},
},
lumi_linkage_alarm: {
key: ["linkage_alarm"],
convertSet: async (entity, key, value, meta) => {
await entity.write("manuSpecificLumi", { 331: { value: value ? 1 : 0, type: 0x20 } }, manufacturerOptions.lumi);
return { state: { linkage_alarm: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x014b], manufacturerOptions.lumi);
},
},
lumi_state: {
key: ["state"],
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x0139], manufacturerOptions.lumi);
},
},
lumi_alarm: {
key: ["gas", "smoke"],
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x013a], manufacturerOptions.lumi);
},
},
lumi_density: {
key: ["gas_density", "smoke_density", "smoke_density_dbm"],
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x013b], manufacturerOptions.lumi);
},
},
lumi_sensitivity: {
key: ["sensitivity"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value, key);
value = value.toLowerCase();
const lookup = { low: 0x04010000, medium: 0x04020000, high: 0x04030000 };
// Timeout of 30 seconds + required (https://github.com/Koenkk/zigbee2mqtt/issues/2287)
const options = { ...manufacturerOptions.lumi, timeout: 35000 };
await entity.write("ssIasZone", { 65521: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x23 } }, options);
return { state: { sensitivity: value } };
},
},
lumi_gas_sensitivity: {
key: ["gas_sensitivity"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertString)(value, key);
value = value.toUpperCase();
const lookup = { "15%LEL": 1, "10%LEL": 2 };
await entity.write("manuSpecificLumi", { 268: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { gas_sensitivity: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [0x010c], manufacturerOptions.lumi);
},
},
// lumi device specific
// biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
lumi_smart_panel_ZNCJMB14LM: {
key: [
"theme",
"standby_enabled",
"beep_volume",
"lcd_brightness",
"language",
"screen_saver_style",
"standby_time",
"font_size",
"lcd_auto_brightness_enabled",
"homepage",
"screen_saver_enabled",
"standby_lcd_brightness",
"available_switches",
"switch_1_text_icon",
"switch_2_text_icon",
"switch_3_text_icon",
],
convertSet: async (entity, key, value, meta) => {
if (key === "theme") {
const lookup = { classic: 0, concise: 1 };
await entity.write("manuSpecificLumi", { 533: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { theme: value } };
}
if (key === "standby_enabled") {
await entity.write("manuSpecificLumi", { 531: { value: value, type: 0x10 } }, manufacturerOptions.lumi);
return { state: { standby_enabled: value } };
}
if (key === "beep_volume") {
const lookup = { mute: 0, low: 1, medium: 2, high: 3 };
await entity.write("manuSpecificLumi", { 530: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { beep_volume: value } };
}
if (key === "lcd_brightness") {
await entity.write("manuSpecificLumi", { 529: { value: value, type: 0x20 } }, manufacturerOptions.lumi);
return { state: { lcd_brightness: value } };
}
if (key === "language") {
const lookup = { chinese: 0, english: 1 };
await entity.write("manuSpecificLumi", { 528: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { language: value } };
}
if (key === "screen_saver_style") {
const lookup = { classic: 1, "analog clock": 2 };
await entity.write("manuSpecificLumi", { 532: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { screen_saver_style: value } };
}
if (key === "standby_time") {
await entity.write("manuSpecificLumi", { 534: { value: value, type: 0x23 } }, manufacturerOptions.lumi);
return { state: { standby_time: value } };
}
if (key === "font_size") {
const lookup = { small: 3, medium: 4, large: 5 };
await entity.write("manuSpecificLumi", { 535: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { font_size: value } };
}
if (key === "lcd_auto_brightness_enabled") {
await entity.write("manuSpecificLumi", { 536: { value: value, type: 0x10 } }, manufacturerOptions.lumi);
return { state: { lcd_auto_brightness_enabled: value } };
}
if (key === "homepage") {
const lookup = { scene: 0, feel: 1, thermostat: 2, switch: 3 };
await entity.write("manuSpecificLumi", { 537: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { homepage: value } };
}
if (key === "screen_saver_enabled") {
await entity.write("manuSpecificLumi", { 545: { value: value, type: 0x10 } }, manufacturerOptions.lumi);
return { state: { screen_saver_enabled: value } };
}
if (key === "standby_lcd_brightness") {
await entity.write("manuSpecificLumi", { 546: { value: value, type: 0x20 } }, manufacturerOptions.lumi);
return { state: { standby_lcd_brightness: value } };
}
if (key === "available_switches") {
const lookup = { none: 0, "1": 1, "2": 2, "1 and 2": 3, "3": 4, "1 and 3": 5, "2 and 3": 6, all: 7 };
await entity.write("manuSpecificLumi", { 555: { value: (0, utils_1.getFromLookup)(value, lookup), type: 0x20 } }, manufacturerOptions.lumi);
return { state: { available_switches: value } };
}
if (key === "switch_1_text_icon") {
const lookup = { "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, "10": 10, "11": 11 };
const payload = [];
const statearr = {};
(0, utils_1.assertObject)(value);
if (value.switch_1_icon != null) {
payload.push((0, utils_1.getFromLookup)(value.switch_1_icon, lookup));
statearr.switch_1_icon = value.switch_1_icon;
}
else {
payload.push(1);
statearr.switch_1_icon = "1";
}
if (value.switch_1_text != null) {
payload.push(...value.switch_1_text.split("").map((c) => c.charCodeAt(0)));
statearr.switch_1_text = value.switch_1_text;
}
else {
// @ts-expect-error ignore
payload.push(..."".text.split("").map((c) => c.charCodeAt(0)));
statearr.switch_1_text = "";
}
await entity.write("manuSpecificLumi", { 547: { value: payload, type: 0x41 } }, manufacturerOptions.lumi);
return { state: statearr };
}
if (key === "switch_2_text_icon") {
const lookup = { "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, "10": 10, "11": 11 };
const payload = [];
const statearr = {};
(0, utils_1.assertObject)(value);
if (value.switch_2_icon != null) {
payload.push((0, utils_1.getFromLookup)(value.switch_2_icon, lookup));
statearr.switch_2_icon = value.switch_2_icon;
}
else {
payload.push(1);
statearr.switch_2_icon = "1";
}
if (value.switch_2_text != null) {
payload.push(...value.switch_2_text.split("").map((c) => c.charCodeAt(0)));
statearr.switch_2_text = value.switch_2_text;
}
else {
// @ts-expect-error ignore
payload.push(..."".text.split("").map((c) => c.charCodeAt(0)));
statearr.switch_2_text = "";
}
await entity.write("manuSpecificLumi", { 548: { value: payload, type: 0x41 } }, manufacturerOptions.lumi);
return { state: statearr };
}
if (key === "switch_3_text_icon") {
const lookup = { "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, "10": 10, "11": 11 };
const payload = [];
const statearr = {};
(0, utils_1.assertObject)(value);
if (value.switch_3_icon != null) {
payload.push((0, utils_1.getFromLookup)(value.switch_3_icon, lookup));
statearr.switch_3_icon = value.switch_3_icon;
}
else {
payload.push(1);
statearr.switch_3_icon = "1";
}
if (value.switch_3_text != null) {
payload.push(...value.switch_3_text.split("").map((c) => c.charCodeAt(0)));
statearr.switch_3_text = value.switch_3_text;
}
else {
// @ts-expect-error ignore
payload.push(..."".text.split("").map((c) => c.charCodeAt(0)));
statearr.switch_3_text = "";
}
await entity.write("manuSpecificLumi", { 549: { value: payload, type: 0x41 } }, manufacturerOptions.lumi);
return { state: statearr };
}
throw new Error(`Not supported: '${key}'`);
},
},
w100_pmtsd: {
key: ["system_mode", "occupied_heating_setpoint", "fan_mode", "unused", "pmtsd_to_w100"],
convertSet: async (entity, key, value, meta) => {
const MIN_SEND_INTERVAL_MS = 5000;
const base = w100EnsureDefaults(meta);
let initialP = 0;
let initialM = 0;
if (base.system_mode === "off") {
initialP = 1;
initialM = meta.device?.meta?.lastActiveMode ?? 0;
}
else {
initialP = 0;
const modeMap = { cool: 0, heat: 1, auto: 2 };
initialM = modeMap[base.system_mode] ?? 0;
}
let initialS = 0;
const speedMap = { auto: 0, low: 1, medium: 2, high: 3 };
initialS = speedMap[base.fan_mode] ?? 0;
const pmtsd = {
p: initialP,
m: initialM,
t: typeof base.occupied_heating_setpoint === "string"
? Number.parseFloat(base.occupied_heating_setpoint)
: base.occupied_heating_setpoint,
s: initialS,
d: typeof base.unused === "string" ? Number.parseInt(base.unused, 10) : base.unused,
};
if (typeof pmtsd.t === "string")
pmtsd.t = Number.parseFloat(pmtsd.t);
if (typeof pmtsd.d === "string")
pmtsd.d = Number.parseInt(pmtsd.d, 10);
let hasChanged = false;
if (key === "pmtsd_to_w100") {
const val = value;
if (val.P !== undefined)
pmtsd.p = Number(val.P);
if (val.M !== undefined)
pmtsd.m = Number(val.M);
if (val.T !== undefined)
pmtsd.t = Number(val.T);
if (val.S !== undefined)
pmtsd.s = Number(val.S);
if (val.D !== undefined)
pmtsd.d = Number(val.D);
hasChanged = true;
}
else {
if (meta.state?.thermostat_mode !== "ON" && ["system_mode", "occupied_heating_setpoint", "fan_mode"].includes(key)) {
logger_1.logger.warning(`Aqara W100: Ignoring ${key} command - thermostat_mode is not ON`, NS);
return { state: {} };
}
switch (key) {
case "system_mode": {
if (value === "off") {
if (pmtsd.p === 0 && pmtsd.m !== undefined) {
if (!meta.device.meta)
meta.device.meta = {};
// biome-ignore lint/suspicious/noExplicitAny: ignored using `--suppress`
meta.device.meta.lastActiveMode = pmtsd.m;
}
if (pmtsd.p !== 1) {
pmtsd.p = 1;
hasChanged = true;
}
}
else {
if (pmtsd.p !== 0) {
pmtsd.p = 0;
hasChanged = true;
}
const modeMap = { cool: 0, heat: 1, auto: 2 };
const numValue = typeof value === "string" ? modeMap[value.toLowerCase()] : Number(value);
if (numValue !== undefined && [0, 1, 2].includes(numValue)) {
if (pmtsd.m !== numValue) {
pmtsd.m = numValue;
hasChanged = true;
if (!meta.device.meta)
meta.device.meta = {};
// biome-ignore lint/suspicious/noExplicitAny: ignored using `--suppress`
meta.device.meta.lastActiveMode = numValue;
}
}
}
break;
}
case "occupied_heating_setpoint": {
const temp = Number.parseFloat(value);
const minTarget = meta.options?.min_target_temp !== undefined ? Number(meta.options.min_target_temp) : w100Defaults.min_target_temp;
const maxTarget = meta.options?.max_target_temp !== undefined ? Number(meta.options.max_target_temp) : w100Defaults.max_target_temp;
if (Number.isNaN(temp) || temp < minTarget || temp > maxTarget)
throw new Error(`occupied_heating_setpoint must be between ${minTarget} and ${maxTarget}`);
if (pmtsd.t !== temp) {
pmtsd.t = temp;
hasChanged = true;
}
break;
}
case "fan_mode": {
let numValue;
if (typeof value === "string") {
const speedMap = { auto: 0, low: 1, medium: 2, high: 3 };
numValue = speedMap[value.toLowerCase()];
}
else {
numValue = Number(value);
}
if (pmtsd.s !== numValue) {
pmtsd.s = numValue;
hasChanged = true;
}
break;
}
case "unused": {
const numValue = typeof value === "string" ? Number.parseInt(value, 10) : Number(value);
if (pmtsd.d !== numValue) {
pmtsd.d = numValue;
hasChanged = true;
}
break;
}
}
}
const modeDisplay = ["cool", "heat", "auto"][pmtsd.m] || "cool";
const speedDisplay = ["auto", "low", "medium", "high"][pmtsd.s] || "auto";
const stateUpdate = {
state: {
occupied_heating_setpoint: pmtsd.t,
fan_mode: speedDisplay,
system_mode: pmtsd.p === 1 ? "off" : modeDisplay,
unused: String(pmtsd.d),
},
};
const { p, m, t, s, d } = pmtsd;
if (p === undefined || m === undefined || t === undefined || s === undefined || d === undefined)
return stateUpdate;
const now = Date.now();
if (meta.device) {
if (!meta.device.meta)
meta.device.meta = {};
}
const lastSendTime = meta.device?.meta?.lastPMTSDSend || 0;
const timeElapsed = now - lastSendTime;
const shouldSend = hasChanged || timeElapsed >= MIN_SEND_INTERVAL_MS;
if (!shouldSend)
return stateUpdate;
await w100SendPMTSD(entity, pmtsd, meta);
return stateUpdate;
},
convertGet: async (entity, key, meta) => {
await entity.read("manuSpecificLumi", [65522], { manufacturerCode: 4447 });
},
},
w100_mode: {
key: ["thermostat_mode"],
convertSet: async (entity, key, value, meta) => {
const deviceMac = meta.device.ieeeAddr.replace(/^0x/, "").toLowerCase();
const hubMac = "54ef4480711a";
function cleanMac(mac, expectedLen) {
const cleaned = mac.replace(/[:-]/g, "");
if (cleaned.length !== expectedLen)
throw new Error(`MAC must be ${expectedLen} hex digits`);
return cleaned;
}
const dev = node_buffer_1.Buffer.from(cleanMac(deviceMac, 16), "hex");
const hub = node_buffer_1.Buffer.from(cleanMac(hubMac, 12), "hex");
let frame;
if (value === "ON") {
const prefix = node_buffer_1.Buffer.concat([
node_buffer_1.Buffer.from("aa713244", "hex"),
node_buffer_1.Buffer.from([Math.floor(Math.random() * 256), Math.floor(Math.random() * 256)]),
]);
const zigbeeHeader = node_buffer_1.Buffer.from("02412f6891", "hex");
const messageId = node_buffer_1.Buffer.from([Math.floor(Math.random() * 256), Math.floor(Math.random() * 256)]);
const control = node_buffer_1.Buffer.from([0x18]);
const payloadMacs = node_buffer_1.Buffer.concat([dev, node_buffer_1.Buffer.from("0000", "hex"), hub]);
const payloadTail = node_buffer_1.Buffer.from("08000844150a0109e7a9bae8b083e58a9f000000000001012a40", "hex");
frame = node_buffer_1.Buffer.concat([prefix, zigbeeHeader, messageId, control, payloadMacs, payloadTail]);
}
else {
const prefix = node_buffer_1.Buffer.from([0xaa, 0x71, 0x1c, 0x44, 0x69, 0x1c, 0x04, 0x41, 0x19, 0x68, 0x91]);
const frameId = node_buffer_1.Buffer.from([Math.floor(Math.random() * 256)]);
const seq = node_buffer_1.Buffer.from([Math.floor(Math.random() * 256)]);
const control = node_buffer_1.Buffer.from([0x18]);
frame = node_buffer_1.Buffer.concat([prefix, frameId, seq, control, dev]);
if (frame.length < 34)
frame = node_buffer_1.Buffer.concat([frame, node_buffer_1.Buffer.alloc(34 - frame.length, 0x00)]);
}
await entity.write(64704, { 65522: { value: frame, type: 0x41 } }, { manufacturerCode: 4447, disableDefaultResponse: true });
logger_1.logger.info(`Aqara W100: thermostat_mode set to ${value}`, NS);
const defaults = w100EnsureDefaults(meta);
return { state: { ...defaults, thermostat_mode: value } };
},
},
lumi_toilet: {
key: [
"lid_switch",
"seat_switch",
"night_light",
"seat_temp",
"cleaning_mode",
"nozzle_position",
"water_pressure",
"water_temp",
"dryer_temp",
"nozzle_clean",
"stop_button",
"flush_big",
"flush_small",
"foam_shield",
"foot_sensor_switch",
"auto_flush_after_leave",
"beeper_switch",
"child_seat_mode",
"pre_mist_switch",
"auto_foam_on_sit",
"auto_foam_on_leave",
],
convertSet: async (entity, key, value, meta) => {
const sendAttr = async (attrCode, value, length) => {
// @ts-expect-error ignore
entity.sendSeq = ((entity.sendSeq || 0) + 1) % 256;
// @ts-expect-error ignore
const val = node_buffer_1.Buffer.from([0x00, 0x02, entity.sendSeq, 0, 0, 0, 0, 0]);
// @ts-expect-error ignore
entity.sendSeq += 1;
val.writeInt32BE(attrCode, 3);
val.writeUInt8(length, 7);
let v = node_buffer_1.Buffer.alloc(length);
switch (length) {
case 1:
v.writeUInt8(value);
break;
case 2:
v.writeUInt16BE(value);
break;
case 4:
v.writeUInt32BE(value);
break;
default:
// @ts-expect-error ignore
v = value;
}
await entity.write("manuSpecificLumi", { 65521: { value: node_buffer_1.Buffer.concat([val, v]), type: 0x41 } }, { manufacturerCode: exports.manufacturerCode });
};
switch (key) {
case "lid_switch":
await sendAttr(0x04030055, (0, utils_1.getFromLookup)(value, { CLOSE: 0, OPEN: 1 }), 1);
break;
case "seat_switch":
await sendAttr(0x04040055, (0, utils_1.getFromLookup)(value, { CLOSE: 0, OPEN: 1 }), 1);
break;
case "night_light":
await sendAttr(0x04200055, (0, utils_1.getFromLookup)(value, { OFF: 0, ON: 1 }), 1);
break;
case "seat_temp":
await sendAttr(0x0e2f0055, (0, utils_1.getFromLookup)(value, { off: 0, temp_31c: 1, temp_33c: 2, temp_35c: 3, temp_37c: 4, temp_39c: 5 }), 4);
break;
case "cleaning_mode":
await sendAttr(0x0e300055, (0, utils_1.getFromLookup)(value, { stop: 0, rear: 1, rear_moving: 2, female: 3, female_moving: 4, child: 5 }), 4);
break;
case "nozzle_position":
await sendAttr(0x0e340055, (0, utils_1.getFromLookup)(value, { back: 0, slightly_back: 1, middle: 2, slightly_front: 3, front: 4 }), 4);
break;
case "water_pressure":
await sendAttr(0x0e330055, (0, utils_1.getFromLookup)(value, { weak: 0, slightly_weak: 1, middle: 2, slightly_strong: 3, strong: 4 }), 4);
break;
case "water_temp":
await sendAttr(0x0e320055, (0, utils_1.getFromLookup)(value, { off: 0, temp_31c: 1, temp_33c: 2, temp_35c: 3, temp_37c: 4, temp_39c: 5 }), 4);
break;
case "dryer_temp":
await sendAttr(0x0e350055, (0, utils_1.getFromLookup)(value, { off: 0, normal: 1, low: 2, mid_low: 3, middle: 4, mid_high: 5, high: 6 }), 4);
break;
case "nozzle_clean":
await sendAttr(0x0e270055, (0, utils_1.getFromLookup)(value, { off: 0, auto: 1, manual: 2 }), 4);
break;
case "stop_button":
await sendAttr(0x04010055, 1, 1);
break;
case "flush_big":
await sendAttr(0x04070055, 1, 1);
break;
case "flush_small":
await sendAttr(0x04020055, 1, 1);
break;
case "foam_shield":
await sendAttr(0x04190055, 0, 1);
break;
case "foot_sensor_switch":
await sendAttr(0x041a0055, (0, utils_1.getFromLookup)(value, { OFF: 0, ON: 1 }), 1);
break;
case "auto_flush_after_leave":
await sendAttr(0x041f0055, (0, utils_1.getFromLookup)(value, { ON: 0, OFF: 1 }), 1);
break;
case "beeper_switch":
await sendAttr(0x04220055, (0, utils_1.getFromLookup)(value, { ON: 0, OFF: 1 }), 1);
break;
case "child_seat_mode":
await sendAttr(0x04240055, (0, utils_1.getFromLookup)(value, { OFF: 0, ON: 1 }), 1);
break;
case "pre_mist_switch":
await sendAttr(0x04250055, (0, utils_1.getFromLookup)(value, { OFF: 0, ON: 1 }), 1);
break;
case "auto_foam_on_sit":
await sendAttr(0x04420055, (0, utils_1.getFromLookup)(value, { OFF: 0, ON: 1 }), 1);
break;
case "auto_foam_on_leave":
await sendAttr(0x04430055, (0, utils_1.getFromLookup)(value, { OFF: 0, ON: 1 }), 1);
break;
}
return { state: { [key]: value } };
},
},
};
//# sourceMappingURL=lumi.js.map