zigbee-herdsman-converters
Version:
Collection of device converters to be used with zigbee-herdsman
960 lines (959 loc) • 416 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.definitions = void 0;
const zigbee_herdsman_1 = require("zigbee-herdsman");
const fz = __importStar(require("../converters/fromZigbee"));
const tz = __importStar(require("../converters/toZigbee"));
const constants = __importStar(require("../lib/constants"));
const ewelink_1 = require("../lib/ewelink");
const exposes = __importStar(require("../lib/exposes"));
const logger_1 = require("../lib/logger");
const m = __importStar(require("../lib/modernExtend"));
const reporting = __importStar(require("../lib/reporting"));
const sonoff_1 = require("../lib/sonoff");
const tuya = __importStar(require("../lib/tuya"));
const utils = __importStar(require("../lib/utils"));
const { ewelinkAction, ewelinkBattery } = ewelink_1.modernExtend;
const NS = "zhc:sonoff";
const manufacturerOptions = {
manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SHENZHEN_COOLKIT_TECHNOLOGY_CO_LTD,
disableDefaultResponse: false,
};
const defaultResponseOptions = { disableDefaultResponse: false };
const disableDefaultResponseOptions = { disableDefaultResponse: true };
const e = exposes.presets;
const ea = exposes.access;
// SWV-ZN/ZF history request cache
const swvzfReqCache = {};
// SWV-ZN/ZF 30-day history multi-package merge
const swvzfRespCache = {};
// SWV-ZN/ZF multi-package merge cache expiration time
const swvzfCacheExpireTime = 5 * 1000; // 5s
// Build a fromZigbee converter for attributes reported as big-endian 32-bit integers.
const bigEndianNumericFzConvert = (name, attributeKey) => {
return (model, msg, publish, options, meta) => {
if (!(attributeKey in msg.data)) {
return;
}
const rawValue = msg.data[attributeKey];
utils.assertNumber(rawValue);
return {
[name]: (0, sonoff_1.toBigEndianUInt32)(rawValue),
};
};
};
// Endpoint-specific composite exposes keep child feature property names unchanged; only the outer object property gets the endpoint suffix.
const exposeCompositeEndpoints = (expose, endpointNames) => {
if (!endpointNames)
return [expose];
return endpointNames.map((endpointName) => {
const endpointExpose = expose.clone();
endpointExpose.endpoint = endpointName;
if (endpointExpose.property) {
endpointExpose.property = `${endpointExpose.property}_${endpointName}`;
}
return endpointExpose;
});
};
// **************************** SWV-ZN/ZF related ↑ ****************************
const sonoffTrvzbtScheduleDays = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
const sonoffTrvzbtTargetTemperatureRange = { min: 5, max: 30, step: 0.5 };
const sonoffTrvzbtFrostProtectionTemperatureRange = { min: 5, max: 15, step: 0.5 };
const sonoffTrvzbtLocalTemperatureCalibrationRange = { min: -10, max: 10, step: 0.2 };
const sonoffTrvzbtTemporaryModeLookup = { boost: 0, timer: 1 };
const sonoffTrvzbtTemporaryModeTemperatureScale = 100;
const sonoffTrvzbtFaultCodeLookup = {
0: "temperature_sensor_issue_detected",
1: "valve_adjustment_issue_detected",
2: "battery_too_low_please_replace_the_batteries",
3: "battery_too_low_for_firmware_upgrade",
4: "battery_status_abnormal",
5: "external_temperature_sensor_connection_issue",
};
const sonoffTrvzbtKnownFaultCodeMask = Object.keys(sonoffTrvzbtFaultCodeLookup).reduce((mask, bit) => mask | (1 << Number(bit)), 0);
const sonoffTrvzbtTemperatureControlHistoryValueOffset = 9;
const sonoffTrvzbtTemperatureControlHistoryCacheTimeoutMs = 30 * 1000;
const sonoffTrvzbtScheduleActiveNumCache = new Map();
const sonoffTrvzbtTemperatureControlHistoryReqCache = {};
const sonoffTrvzbtTemperatureControlHistoryRespCache = {};
const getValidSonoffTrvzbtScheduleActiveNum = (value) => {
const activeNum = Number(value);
if (!Number.isInteger(activeNum) || activeNum < 0 || activeNum > 0xff)
return;
return activeNum;
};
const getSonoffTrvzbtDeviceCacheKey = (endpoint, device) => {
if (device?.ieeeAddr)
return device.ieeeAddr;
return endpoint?.getDevice?.()?.ieeeAddr;
};
const getSonoffTrvzbtTemperatureControlHistoryCacheKey = (endpoint, device) => {
return getSonoffTrvzbtDeviceCacheKey(endpoint, device);
};
const getSonoffTrvzbtTemperatureControlHistoryRespCacheKey = (cacheKey, subCmd) => {
return `${cacheKey}:${subCmd}`;
};
const clearSonoffTrvzbtTemperatureControlHistoryCache = (cacheKey, subCmd) => {
delete sonoffTrvzbtTemperatureControlHistoryRespCache[getSonoffTrvzbtTemperatureControlHistoryRespCacheKey(cacheKey, subCmd)];
if (sonoffTrvzbtTemperatureControlHistoryReqCache[cacheKey]) {
delete sonoffTrvzbtTemperatureControlHistoryReqCache[cacheKey][subCmd];
}
};
const getSonoffTrvzbtTemperatureControlHistoryDisplayOffsetSeconds = (value) => {
if (value.endsWith("Z"))
return 0;
const matches = value.match(/([+-])(\d{2}):(\d{2})$/);
if (!matches)
return;
const sign = matches[1] === "-" ? -1 : 1;
return sign * (Number.parseInt(matches[2], 10) * 3600 + Number.parseInt(matches[3], 10) * 60);
};
const getSonoffTrvzbtTemperatureControlHistoryIntervalEnd = (type, startSec, offsetSeconds) => {
if (type === "day")
return startSec + 3600;
if (type === "month")
return startSec + 86400;
return (0, sonoff_1.shiftUtcSecondsByOffsetMonths)(startSec, 1, offsetSeconds);
};
const buildSonoffTrvzbtTemperatureControlHistoryData = (type, values, request) => {
const records = [];
let intervalStartSec = request.startUTC;
for (const value of values) {
const intervalEndSec = getSonoffTrvzbtTemperatureControlHistoryIntervalEnd(type, intervalStartSec, request.displayOffsetSeconds);
records.push({
value,
startTime: (0, sonoff_1.formatUtcSecondsToIsoWithOffset)(intervalStartSec, request.displayOffsetSeconds),
endTime: (0, sonoff_1.formatUtcSecondsToIsoWithOffset)(intervalEndSec, request.displayOffsetSeconds),
});
intervalStartSec = intervalEndSec;
}
return records;
};
const formatSonoffTrvzbtTemperatureControlHistoryOutputData = (records) => {
return records.map((record) => ({
value: record.value,
start_time: record.startTime,
end_time: record.endTime,
}));
};
const buildSonoffTrvzbtTemperatureControlHistoryResult = (request, state) => {
const packets = Object.values(state.packets).sort((a, b) => a.current - b.current);
const valuesByDataType = {};
for (const historyPacket of packets) {
valuesByDataType[historyPacket.dataType] ??= [];
valuesByDataType[historyPacket.dataType].push(...historyPacket.values);
}
return {
type: request.type,
time_range: request.timeRange,
temperature_data: formatSonoffTrvzbtTemperatureControlHistoryOutputData(buildSonoffTrvzbtTemperatureControlHistoryData(request.type, valuesByDataType[0x00] ?? [], request)),
target_temperature_data: formatSonoffTrvzbtTemperatureControlHistoryOutputData(buildSonoffTrvzbtTemperatureControlHistoryData(request.type, valuesByDataType[0x02] ?? [], request)),
};
};
const isSonoffTrvzbtTemperatureControlHistoryComplete = (state) => {
return (Object.keys(state.packets).length === state.total &&
Array.from({ length: state.total }, (_, index) => state.packets[index] !== undefined).every(Boolean));
};
const formatSonoffTrvzbtFaultCode = (value) => {
logger_1.logger.info(`TRV-ZBT formatSonoffTrvzbtFaultCode input: value=${value} (type=${typeof value})`, NS);
const faultCode = Number(value);
if (!Number.isInteger(faultCode) || faultCode < 0 || faultCode > 0xffffffff) {
logger_1.logger.info(`TRV-ZBT formatSonoffTrvzbtFaultCode result: "unknown" (invalid input)`, NS);
return "unknown";
}
const rawValue = faultCode >>> 0;
const descriptions = Object.entries(sonoffTrvzbtFaultCodeLookup)
.filter(([bit]) => (rawValue & (1 << Number(bit))) !== 0)
.map(([, description]) => description);
logger_1.logger.info(`TRV-ZBT formatSonoffTrvzbtFaultCode: rawValue=${rawValue} (0x${rawValue.toString(16).padStart(8, "0")}), matchedBits=${JSON.stringify(descriptions)}`, NS);
if (descriptions.length === 0) {
const result = rawValue === 0 ? "none" : "unknown";
logger_1.logger.info(`TRV-ZBT formatSonoffTrvzbtFaultCode result: "${result}" (no known bits matched)`, NS);
return result;
}
if ((rawValue & ~sonoffTrvzbtKnownFaultCodeMask) !== 0) {
descriptions.push("unknown");
}
const result = descriptions.join(", ");
logger_1.logger.info(`TRV-ZBT formatSonoffTrvzbtFaultCode result: "${result}"`, NS);
return result;
};
const cacheSonoffTrvzbtScheduleActiveNum = (activeNum, endpoint, device) => {
const cacheKey = getSonoffTrvzbtDeviceCacheKey(endpoint, device);
if (cacheKey) {
sonoffTrvzbtScheduleActiveNumCache.set(cacheKey, activeNum);
}
};
const getCachedSonoffTrvzbtScheduleActiveNum = (endpoint, device) => {
const cacheKey = getSonoffTrvzbtDeviceCacheKey(endpoint, device);
return cacheKey ? sonoffTrvzbtScheduleActiveNumCache.get(cacheKey) : undefined;
};
const readSonoffTrvzbtScheduleActiveNum = async (entity, device, reason) => {
const endpoint = "read" in entity && typeof entity.read === "function" ? entity : undefined;
if (endpoint) {
try {
const readResult = await endpoint.read("customSonoffTrvzbt", ["weeklyScheduleActiveNum"]);
const activeNum = getValidSonoffTrvzbtScheduleActiveNum(readResult.weeklyScheduleActiveNum);
if (activeNum !== undefined) {
cacheSonoffTrvzbtScheduleActiveNum(activeNum, endpoint, device);
logger_1.logger.info(`TRV-ZBT ${reason}: active schedule group=${activeNum}`, NS);
return activeNum;
}
logger_1.logger.warning(`TRV-ZBT ${reason}: invalid active schedule group=${readResult.weeklyScheduleActiveNum}`, NS);
}
catch (error) {
logger_1.logger.warning(`TRV-ZBT ${reason}: read active schedule group failed, ${error}`, NS);
}
}
return getCachedSonoffTrvzbtScheduleActiveNum(endpoint, device) ?? 0;
};
const formatSonoffTrvzbtPayload = (payload) => {
return `[${Array.from(payload)
.map((byte) => `0x${byte.toString(16).padStart(2, "0")}`)
.join(", ")}]`;
};
const shouldMirrorSonoffTrvzbtActiveSchedule = (activeNum, meta, endpoint, device) => {
return activeNum === (getCachedSonoffTrvzbtScheduleActiveNum(endpoint, device ?? meta.device) ?? 0);
};
const parseSonoffTrvzbtScheduleString = (scheduleValue, dayName) => {
const transitionRegex = /^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9])\/(\d+(?:\.\d{1,2})?)$/;
const rawTransitions = scheduleValue.trim().split(/\s+/).sort();
if (rawTransitions.length > 6) {
throw new Error(`Invalid schedule for ${dayName}: days must have no more than 6 transitions`);
}
const transitions = [];
for (const transition of rawTransitions) {
const matches = transition.match(transitionRegex);
if (!matches) {
throw new Error(`Invalid schedule for ${dayName}: transitions must be in format HH:mm/temperature (e.g. 12:00/15.5), found: ${transition}`);
}
const hour = Number.parseInt(matches[1], 10);
const mins = Number.parseInt(matches[2], 10);
const temp = Number.parseFloat(matches[3]);
if (temp < sonoffTrvzbtTargetTemperatureRange.min || temp > sonoffTrvzbtTargetTemperatureRange.max) {
throw new Error(`Invalid schedule for ${dayName}: temperature value must be between ${sonoffTrvzbtTargetTemperatureRange.min}-${sonoffTrvzbtTargetTemperatureRange.max} (inclusive), found: ${temp}`);
}
transitions.push({
transitionTime: hour * 60 + mins,
heatSetpoint: Math.round(temp * 100),
});
}
if (transitions[0].transitionTime !== 0) {
throw new Error(`Invalid schedule for ${dayName}: the first transition of each day should start at 00:00`);
}
return { numoftrans: rawTransitions.length, transitions };
};
const isValidSonoffTrvzbtScheduleTransition = ({ transitionTime, heatSetpoint }) => {
const temperature = heatSetpoint / 100;
return (Number.isInteger(transitionTime) &&
transitionTime >= 0 &&
transitionTime < 24 * 60 &&
temperature >= sonoffTrvzbtTargetTemperatureRange.min &&
temperature <= sonoffTrvzbtTargetTemperatureRange.max);
};
const formatSonoffTrvzbtScheduleTransitions = (transitions) => {
return [...transitions]
.filter(isValidSonoffTrvzbtScheduleTransition)
.sort((a, b) => a.transitionTime - b.transitionTime || a.heatSetpoint - b.heatSetpoint)
.map((transition) => {
const hours = Math.floor(transition.transitionTime / 60);
const minutes = transition.transitionTime % 60;
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}/${transition.heatSetpoint / 100}`;
})
.join(" ");
};
const getSonoffTrvzbtDayBit = (dayName) => {
const dayKey = utils.getKey(constants.thermostatDayOfWeek, dayName, null);
if (dayKey === null) {
throw new Error(`Invalid schedule: invalid day name, found: ${dayName}`);
}
return Number(dayKey);
};
const getSonoffTrvzbtScheduleDayNames = (dayofweek) => {
return sonoffTrvzbtScheduleDays.filter((day) => (dayofweek & (1 << getSonoffTrvzbtDayBit(day))) !== 0);
};
const buildSonoffTrvzbtSchedulePayload = (activeNum, dayofweek, transitions) => {
const payload = [0x01, 0x01, activeNum, transitions.length, dayofweek, 0x01];
for (const transition of transitions) {
payload.push(transition.transitionTime & 0xff, (transition.transitionTime >> 8) & 0xff);
payload.push(transition.heatSetpoint & 0xff, (transition.heatSetpoint >> 8) & 0xff);
}
return payload;
};
const sendSonoffTrvzbtScheduleReadCommand = async (entity, activeNum, reason) => {
const payload = [0x01, 0x00, activeNum];
logger_1.logger.info(`TRV-ZBT ${reason} scheduleGroup activeNum=${activeNum} payload=${formatSonoffTrvzbtPayload(payload)}`, NS);
await entity.command("customSonoffTrvzbt", "scheduleGroup", { data: payload }, disableDefaultResponseOptions);
};
const fzLocal = {
key_action_event: {
cluster: "customSonoffSnzb01m",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if ("keyActionEvent" in msg.data) {
const event = utils.getFromLookup(msg.data.keyActionEvent, { 1: "single", 2: "double", 3: "long", 4: "triple" });
return { action: `${event}_button_${msg.endpoint.ID}` };
}
},
},
router_config: {
cluster: "genLevelCtrl",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const result = {};
if (msg.data.currentLevel !== undefined) {
result.light_indicator_level = msg.data.currentLevel;
}
},
},
on_off_clear_electricity: {
cluster: "genOnOff",
type: ["attributeReport", "readResponse"],
options: [exposes.options.state_action()],
convert: (model, msg, publish, options, meta) => {
// Device keeps reporting a acCurrentPowerValue after turning OFF.
// Make sure power = 0 when turned OFF
// https://github.com/Koenkk/zigbee2mqtt/issues/28470
let result = fz.on_off.convert(model, msg, publish, options, meta);
if (msg.data.onOff === 0) {
result = { ...result, power: 0, current: 0 };
}
return result;
},
},
snzb_09p_alert: {
cluster: "customClusterEwelink",
type: ["commandAlertCommand", "raw"],
convert: (model, msg, publish, options, meta) => {
let data;
if (msg.type === "raw") {
if (!(msg.data instanceof Buffer) || msg.data.length < 5 || msg.data[2] !== 0x0f) {
return;
}
data = msg.data.subarray(3);
}
else {
if (!("data" in msg.data)) {
return;
}
data = Buffer.isBuffer(msg.data.data) ? msg.data.data : Buffer.from(msg.data.data);
}
if (!data || data.length < 2 || data[0] !== 4) {
return;
}
const alarmType = { 0: "none", 1: "manual", 2: "scene" }[data[1]];
if (alarmType == null) {
return;
}
return { alarm_type: alarmType, siren_on: alarmType === "none" ? "OFF" : "ON" };
},
},
};
const tzLocal = {
on_off_fixed_on_time: {
...tz.on_off,
convertSet: async (entity, key, value, meta) => {
// https://github.com/Koenkk/zigbee2mqtt/issues/27980
const localMeta = meta;
if (localMeta.message.on_time != null) {
utils.assertNumber(localMeta.message.on_time, "on_time");
localMeta.message = { ...localMeta.message, on_time: localMeta.message.on_time / 10 };
}
return await tz.on_off.convertSet(entity, key, value, localMeta);
},
},
snzb_09p_alert: {
key: ["start_manual_alarm", "cancel_alarm", "start_scene_alarm", "siren_on"],
convertSet: async (entity, key, value, meta) => {
const state = meta.state || {};
const device = meta.device;
if (!device)
return;
const endpoint = device.getEndpoint(1);
if (!endpoint)
return;
let payload;
switch (key) {
case "cancel_alarm":
payload = Buffer.from([1]);
break;
case "siren_on": {
if (value === "OFF" || value === false) {
payload = Buffer.from([1]);
break;
}
const voice = state.alarm_sound_enable === "ON" || state.alarm_sound_enable === true ? 0x01 : 0x00;
const light = state.alarm_light_enable === "ON" || state.alarm_light_enable === true ? 0x01 : 0x00;
const alertSoundRaw = meta.message?.alarm_sound_type ?? state.alarm_sound_type ?? 0;
const alertSoundParsed = typeof alertSoundRaw === "string" ? Number.parseInt(alertSoundRaw.replace(/^sound\s+/i, ""), 10) : Number(alertSoundRaw);
const alertSound = Number.isFinite(alertSoundParsed) ? Math.min(9, Math.max(0, alertSoundParsed)) : 0;
const volMap = { low: 0, medium: 1, high: 2, highest: 3 };
const volumeLevel = String(state.alarm_volume_level ?? "high").toLowerCase();
const volume = volMap[volumeLevel] ?? 2;
const duration = Math.min(900, Math.max(1, Number(state.alarm_duration ?? 10)));
payload = Buffer.from([0x02, 0x00, voice, light, alertSound, volume, duration & 0xff, (duration >> 8) & 0xff, 0x00]);
break;
}
default:
throw new Error(`Unsupported SNZB-09P alert command key '${key}'`);
}
await endpoint.command("customClusterEwelink", "alertCommand", { data: payload }, { disableDefaultResponse: true });
if (key === "siren_on") {
return { state: { siren_on: value === "ON" || value === true ? "ON" : "OFF" } };
}
return {};
},
},
};
const sonoffExtend = {
addCustomClusterEwelink: () => {
return m.deviceAddCustomCluster("customClusterEwelink", {
name: "customClusterEwelink",
ID: 0xfc11,
attributes: {
networkLed: { name: "networkLed", ID: 0x0001, type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN, write: true },
backLight: { name: "backLight", ID: 0x0002, type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN, write: true },
faultCode: { name: "faultCode", ID: 0x0010, type: zigbee_herdsman_1.Zcl.DataType.INT32, write: true, min: -2147483648 },
radioPower: { name: "radioPower", ID: 0x0012, type: zigbee_herdsman_1.Zcl.DataType.INT16, write: true, min: -32768 },
radioPowerWithManuCode: {
name: "radioPowerWithManuCode",
ID: 0x0012,
type: zigbee_herdsman_1.Zcl.DataType.INT16,
manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SHENZHEN_COOLKIT_TECHNOLOGY_CO_LTD,
write: true,
min: -32768,
},
delayedPowerOnState: { name: "delayedPowerOnState", ID: 0x0014, type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN, write: true },
delayedPowerOnTime: { name: "delayedPowerOnTime", ID: 0x0015, type: zigbee_herdsman_1.Zcl.DataType.UINT16, write: true, max: 0xffff },
externalTriggerMode: { name: "externalTriggerMode", ID: 0x0016, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
detachRelayMode: { name: "detachRelayMode", ID: 0x0017, type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN, write: true },
deviceWorkMode: { name: "deviceWorkMode", ID: 0x0018, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
detachRelayMode2: { name: "detachRelayMode2", ID: 0x0019, type: zigbee_herdsman_1.Zcl.DataType.BITMAP8, write: true },
motorTravelCalibrationAction: { name: "motorTravelCalibrationAction", ID: 0x5001, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
lackWaterCloseValveTimeout: { name: "lackWaterCloseValveTimeout", ID: 0x5011, type: zigbee_herdsman_1.Zcl.DataType.UINT16, write: true, max: 0xffff },
motorTravelCalibrationStatus: { name: "motorTravelCalibrationStatus", ID: 0x5012, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
motorRunStatus: { name: "motorRunStatus", ID: 0x5013, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
acCurrentCurrentValue: { name: "acCurrentCurrentValue", ID: 0x7004, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff },
acCurrentVoltageValue: { name: "acCurrentVoltageValue", ID: 0x7005, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff },
acCurrentPowerValue: { name: "acCurrentPowerValue", ID: 0x7006, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff },
outlet_control_protect: { name: "outlet_control_protect", ID: 0x7007, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
energyToday: { name: "energyToday", ID: 0x7009, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff },
energyMonth: { name: "energyMonth", ID: 0x700a, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff },
energyYesterday: { name: "energyYesterday", ID: 0x700b, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff },
setCalibrationAction: { name: "setCalibrationAction", ID: 0x001d, type: zigbee_herdsman_1.Zcl.DataType.CHAR_STR, write: true },
calibrationStatus: { name: "calibrationStatus", ID: 0x001e, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
calibrationProgress: { name: "calibrationProgress", ID: 0x0020, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
minBrightnessThreshold: { name: "minBrightnessThreshold", ID: 0x4001, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
maxBrightnessThreshold: { name: "maxBrightnessThreshold", ID: 0x4002, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
dimmingLightRate: { name: "dimmingLightRate", ID: 0x4003, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
transitionTime: { name: "transitionTime", ID: 0x001f, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff },
levelForCalibration: { name: "levelForCalibration", ID: 0x4006, type: zigbee_herdsman_1.Zcl.DataType.UINT8 },
programmableStepperSequence: { name: "programmableStepperSequence", ID: 0x0022, type: zigbee_herdsman_1.Zcl.DataType.ARRAY, write: true },
},
commands: {
protocolData: { name: "protocolData", ID: 0x01, parameters: [{ name: "data", type: zigbee_herdsman_1.Zcl.BuffaloZclDataType.LIST_UINT8 }] },
},
commandsResponse: {},
});
},
occupancyZoneEnable(deviceZoneCount, zoneStep, mergeFirstTwoZone = false) {
const exposedZoneCount = mergeFirstTwoZone ? deviceZoneCount - 1 : deviceZoneCount;
// Generate interval distance description
const getZoneRange = (zoneIndex) => {
if (mergeFirstTwoZone) {
if (zoneIndex === 0) {
return `0m-${zoneStep * 2}m`;
}
const start = zoneStep * 2 + (zoneIndex - 1) * zoneStep;
const end = start + zoneStep;
return `${start}m-${end}m`;
}
const start = zoneIndex * zoneStep;
const end = start + zoneStep;
return `${start}m-${end}m`;
};
const exposes = Array.from({ length: exposedZoneCount }, (_, i) => e
.binary(`enable_occupancy_zone_${i + 1}`, ea.ALL, true, false)
.withLabel(`Zone ${i + 1} (${getZoneRange(i)})`)
.withCategory("config"));
const toZigbee = [
{
key: Array.from({ length: exposedZoneCount }, (_, i) => `enable_occupancy_zone_${i + 1}`),
convertSet: async (entity, key, value, meta) => {
const zone = Number.parseInt(key.split("_").at(-1) ?? "0", 10);
// Rebuild bitmap from current states
let bitmap = 0;
for (let z = 1; z <= exposedZoneCount; z++) {
if (meta.state[`enable_occupancy_zone_${z}`]) {
if (mergeFirstTwoZone && z === 1) {
bitmap |= 0b11; // bit0 + bit1
}
else {
const bitIndex = mergeFirstTwoZone ? z : z - 1;
bitmap |= 1 << bitIndex;
}
}
}
// Apply current change to bitmap
if (mergeFirstTwoZone && zone === 1) {
if (value) {
bitmap |= 0b11;
}
else {
bitmap &= ~0b11;
}
}
else {
const bitIndex = mergeFirstTwoZone ? zone : zone - 1;
if (value) {
bitmap |= 1 << bitIndex;
}
else {
bitmap &= ~(1 << bitIndex);
}
}
await entity.write("customClusterEwelink", {
[0x2016]: {
value: bitmap,
type: zigbee_herdsman_1.Zcl.DataType.BITMAP16,
},
}, utils.getOptions(meta.mapped, entity));
return {
state: {
[key]: value,
},
};
},
},
];
const fromZigbee = [
{
cluster: "customClusterEwelink",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (!msg.data.occupancyZoneEnable) {
return;
}
const bitmap = msg.data.occupancyZoneEnable;
const result = {};
if (mergeFirstTwoZone) {
// zone 1
result["enable_occupancy_zone_1"] = (bitmap & 0b11) !== 0;
// zone 2~N
for (let i = 2; i <= deviceZoneCount; i++) {
result[`enable_occupancy_zone_${i}`] = (bitmap & (1 << i)) !== 0;
}
}
else {
for (let i = 0; i < deviceZoneCount; i++) {
result[`enable_occupancy_zone_${i + 1}`] = (bitmap & (1 << i)) !== 0;
}
}
return result;
},
},
];
return {
exposes,
fromZigbee,
toZigbee,
isModernExtend: true,
};
},
spatialLearning() {
const commandName = "spatialLearning";
const exposes = [
e.enum("spatial_learning", ea.SET, ["start_learning"]).withDescription("Start space learning calibration").withCategory("config"),
e.enum("spatial_learning_state", ea.STATE, ["Clear", "Learning", "Failed"]).withDescription("Current state of space learning"),
];
const toZigbee = [
{
key: ["spatial_learning"],
convertSet: async (entity, key, value, meta) => {
if (value === "start_learning") {
const payloadValue = [];
payloadValue[0] = 0x00; // Sub cmd = 0
// sequence (uint64_t) - timestamp
const seqBuffer = Buffer.alloc(8);
seqBuffer.writeBigUInt64LE(BigInt(Date.now()));
for (let i = 0; i < seqBuffer.length; i++) {
payloadValue[1 + i] = seqBuffer[i];
}
const payload = { data: payloadValue };
await entity.command("customClusterEwelink", commandName, payload, defaultResponseOptions);
}
return {
state: {
spatial_learning_state: "Learning",
},
};
},
},
];
const fromZigbee = [
{
cluster: "customClusterEwelink",
type: ["raw"],
convert: (model, msg, publish, options, meta) => {
const buffer = Buffer.from(msg.data);
const cmd = buffer[2];
if (![0x04].includes(cmd))
return;
logger_1.logger.warning(`spatialLearning: received msg=${JSON.stringify(msg)}`, NS);
const subCmd = buffer[3];
// Respond to start learning
if (subCmd === 0x01) {
if (buffer.length < 20) {
logger_1.logger.warning(`spatialLearning: subCmd=0x01 invalid payload len=${buffer.length}`, NS);
return;
}
const sequence = buffer.readBigUInt64LE(4);
const expectEndTime = buffer.readBigUInt64LE(12);
logger_1.logger.info(`spatialLearning: start response seq=${sequence.toString()} expect_end_time=${expectEndTime.toString()} === ${expectEndTime.toLocaleString()}`, NS);
return {
spatial_learning_state: "Learning",
};
}
// Result of learning
if (subCmd === 0x02) {
if (buffer.length < 14) {
logger_1.logger.warning(`spatialLearning: subCmd=0x02 invalid payload len=${buffer.length}`, NS);
return;
}
const sequence = buffer.readBigUInt64LE(4);
const state = buffer[12];
const reason = buffer[13];
logger_1.logger.info(`spatialLearning: result seq=${sequence.toString()} state=${state} reason=${reason}`, NS);
if (state === 0x00 && reason === 0x00) {
return { spatial_learning_state: "Clear" };
}
return { spatial_learning_state: "Failed" };
}
logger_1.logger.warning(`spatialLearning: unknown subCmd=0x${subCmd.toString(16).padStart(2, "0")}`, NS);
},
},
];
return {
exposes,
fromZigbee,
toZigbee,
isModernExtend: true,
};
},
programmableStepperSequence(sequences) {
const stepComposite = (n) => {
return e
.composite(`step_${n}`, `step_${n}`, ea.ALL)
.withFeature(e.binary("enable_step", ea.ALL, true, false).withDescription("Enable/disable this step."))
.withFeature(e.binary("relay_outlet_1", ea.ALL, true, false).withDescription("Outlet 1 relay state."))
.withFeature(e.binary("relay_outlet_2", ea.ALL, true, false).withDescription("Outlet 2 relay state."));
};
const exposes = sequences.map((seq) => {
return e
.composite(`programmable_stepper_seq${seq}`, `programmable_stepper_seq${seq}`, ea.ALL)
.withDescription(`Configure programmable stepper sequence ${seq}.`)
.withFeature(e.binary("enable_stepper", ea.ALL, true, false).withDescription("Enable/disable the stepper sequence."))
.withFeature(e
.numeric("switch_outlet", ea.ALL)
.withValueMin(1)
.withValueMax(2)
.withValueStep(1)
.withDescription("The outlet channel of the external trigger switch bound to this sequence."))
.withFeature(e.binary("enable_double_press", ea.ALL, true, false).withDescription("Enable/disable double press to switch steps."))
.withFeature(e
.numeric("double_press_interval", ea.ALL)
.withValueMin(0)
.withValueMax(32767)
.withValueStep(1)
.withUnit("ms")
.withDescription("Set the double press interval for step switching."))
.withFeature(stepComposite(1))
.withFeature(stepComposite(2))
.withFeature(stepComposite(3))
.withFeature(stepComposite(4));
});
const toZigbee = [
{
key: [...sequences.map((seq) => `programmable_stepper_seq${seq}`)],
convertSet: async (entity, key, value, meta) => {
utils.assertObject(value, key);
const array = new Uint8Array(11);
// ZCL Array
array[0] = 0x01;
array[1] = 9;
array[2] = 1;
// Sequence configs
const seqStr = key.replace("programmable_stepper_seq", "");
const seqIndex = Number.parseInt(seqStr, 10) - 1;
array[3] = (value.enable_stepper ? 0x80 : 0x00) | (seqIndex & 0x7f);
array[4] = (value.switch_outlet - 1) & 0xff;
array[5] = (value.enable_double_press ? 0x80 : 0x00) | ((value.double_press_interval >> 8) & 0x7f);
array[6] = value.double_press_interval & 0xff;
// Steps
for (let i = 0; i < 4; i++) {
const step = value[`step_${i + 1}`] ?? {};
array[7 + i] = (step.enable_step ? 0x80 : 0x00) | (step.relay_outlet_1 ? 0x01 : 0x00) | (step.relay_outlet_2 ? 0x02 : 0x00);
}
await entity.write("customClusterEwelink", {
[0x0022]: {
value: {
elementType: 0x20,
elements: array,
},
type: 0x48,
},
}, utils.getOptions(meta.mapped, entity));
return {
state: {
[key]: value,
},
};
},
},
];
const fromZigbee = [
{
cluster: "customClusterEwelink",
type: ["attributeReport"],
convert: (model, msg) => {
if (!msg.data?.programmableStepperSequence) {
return;
}
const array = new Uint8Array(msg.data.programmableStepperSequence);
if (array[0] !== 0x01) {
return;
}
const seqCount = array[2];
const seqDataOffset = 3;
const result = {};
for (let i = 0; i < seqCount; i++) {
const offset = seqDataOffset + i * 8;
// Steps
const steps = {};
for (let j = 0; j < 4; j++) {
const currentBuffer = array[offset + 4 + j];
steps[`step_${j + 1}`] = {
enable_step: !!(currentBuffer & 0x80),
relay_outlet_1: !!(currentBuffer & 0x01),
relay_outlet_2: !!(currentBuffer & 0x02),
};
}
// Sequence configs
const seqNum = (array[offset] & 0x7f) + 1;
result[`programmable_stepper_seq${seqNum}`] = {
enable_stepper: !!(array[offset] & 0x80),
switch_outlet: array[offset + 1] + 1,
enable_double_press: !!(array[offset + 2] & 0x80),
double_press_interval: ((array[offset + 2] & 0x7f) << 8) | array[offset + 3],
...steps,
};
}
return result;
},
},
];
return {
exposes,
fromZigbee,
toZigbee,
isModernExtend: true,
};
},
inchingControlSet: (args = {}, maxTime = 3599.5) => {
const { endpointNames = undefined } = args;
const clusterName = "customClusterEwelink";
const commandName = "protocolData";
const exposes = utils.exposeEndpoints(e
.composite("inching_control_set", "inching_control_set", ea.SET)
.withDescription("Device Inching function Settings. The device will automatically turn off (turn on) " +
"after each turn on (turn off) for a specified period of time.")
.withFeature(e.binary("inching_control", ea.SET, "ENABLE", "DISABLE").withDescription("Enable/disable inching function."))
.withFeature(e
.numeric("inching_time", ea.SET)
.withDescription("Delay time for executing a inching action.")
.withUnit("seconds")
.withValueMin(0.5)
.withValueMax(maxTime)
.withValueStep(0.5))
.withFeature(e.binary("inching_mode", ea.SET, "ON", "OFF").withDescription("Set inching off or inching on mode.").withValueToggle("ON")), endpointNames);
const toZigbee = [
{
key: ["inching_control_set"],
convertSet: async (entity, key, value, meta) => {
let inchingControl = "inching_control";
let inchingTime = "inching_time";
let inchingMode = "inching_mode";
if (meta.endpoint_name) {
inchingControl = `inching_control_${meta.endpoint_name}`;
inchingTime = `inching_time_${meta.endpoint_name}`;
inchingMode = `inching_mode_${meta.endpoint_name}`;
}
const tmpTime = Number(Math.round(Number((value[inchingTime] * 2).toFixed(1))).toFixed(1));
const payloadValue = [];
payloadValue[0] = 0x01; // Cmd
payloadValue[1] = 0x17; // SubCmd
payloadValue[2] = 0x07; // Length
payloadValue[3] = 0x80; // SeqNum
payloadValue[4] = 0x00; // Mode
if (value[inchingControl] !== "DISABLE") {
payloadValue[4] |= 0x80;
}
if (value[inchingMode] !== "OFF") {
payloadValue[4] |= 0x01;
}
if (meta.endpoint_name === "l2") {
payloadValue[5] = 0x01; // Channel 2
}
else {
payloadValue[5] = 0x00; // Channel 1
}
payloadValue[6] = tmpTime & 0xff; // Timeout
payloadValue[7] = (tmpTime >> 8) & 0xff;
payloadValue[8] = 0x00; // Reserve
payloadValue[9] = 0x00;
payloadValue[10] = 0x00; // CheckCode
for (let i = 0; i < payloadValue[2] + 3; i++) {
payloadValue[10] ^= payloadValue[i];
}
await entity.command(clusterName, commandName, { data: payloadValue }, { manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SHENZHEN_COOLKIT_TECHNOLOGY_CO_LTD });
return { state: { [key]: value } };
},
},
];
return {
exposes: exposes,
fromZigbee: [],
toZigbee,
isModernExtend: true,
};
},
weeklySchedule: () => {
const scheduleDescription = 'The preset heating schedule to use when the system mode is set to "auto" (indicated with ⏲ on the TRV). ' +
"Up to 6 transitions can be defined per day, where a transition is expressed in the format 'HH:mm/temperature', each " +
"separated by a space. The first transition for each day must start at 00:00 and the valid temperature range is 4-35°C " +
"(in 0.5°C steps). The temperature will be set at the time of the first transition until the time of the next transition, " +
"e.g. '04:00/20 10:00/25' will result in the temperature being set to 20°C at 04:00 until 10:00, when it will change to 25°C.";
const days = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
const exposes = days.map((day) => e.text(`weekly_schedule_${day}`, ea.STATE_SET).withCategory("config").withDescription(scheduleDescription));
const fromZigbee = [
{
cluster: "hvacThermostat",
type: ["commandGetWeeklyScheduleRsp"],
convert: (model, msg, publish, options, meta) => {
const day = Object.entries(constants.thermostatDayOfWeek).find((d) => msg.data.dayofweek & (1 << +d[0]))[1];
const transitions = msg.data.transitions
// TODO: heatSetpoint is optional, that possibly affects the return
.map((t) => {
const totalMinutes = t.transitionTime;
const hours = totalMinutes / 60;
const rHours = Math.floor(hours);
const minutes = (hours - rHours) * 60;
const rMinutes = Math.round(minutes);
const strHours = rHours.toString().padStart(2, "0");
const strMinutes = rMinutes.toString().padStart(2, "0");
return `${strHours}:${strMinutes}/${t.heatSetpoint / 100}`;
})
.sort()
.join(" ");
return {
[`weekly_schedule_${day}`]: transitions,
};
},
},
];
// Helper function to parse and validate a schedule string
const parseScheduleString = (scheduleValue, dayName) => {
// Transition format: HH:mm/temperature
const transitionRegex = /^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9])\/(\d+(\.5)?)$/;
const rawTransitions = scheduleValue.split(" ").sort();
if (rawTransitions.length > 6) {
throw new Error(`Invalid schedule for ${dayName}: days must have no more than 6 transitions`);
}
const transitions = [];
for (const transition of rawTransitions) {
const matches = transition.match(transitionRegex);
if (!matches) {
throw new Error(`Invalid schedule for ${dayName}: transitions must be in format HH:mm/temperature (e.g. 12:00/15.5), found: ${transition}`);
}
const hour = Number.parseInt(matches[1], 10);
const mins = Number.parseInt(matches[2], 10);
const temp = Number.parseFloat(matches[3]);
if (temp < 4 || temp > 35) {
throw new Error(`Invalid schedule for ${dayName}: temperature value must be between 4-35 (inclusive), found: ${temp}`);
}
transitions.push({
transitionTime: hour * 60 + mins,
heatSetpoint: Math.round(temp * 100),
});
}
if (transitions[0].transitionTime !== 0) {
throw new Error(`Invalid schedule for ${dayName}: the first transition of each day should start at 00:00`);
}
return {
numoftrans: rawTransitions.length,
transitions,
};
};
// Helper function to get day bit from day name
const getDayBit = (dayName) => {
const dayKey = utils.getKey(constants.thermostatDayOfWeek, dayName, null);
if (dayKey === null) {
throw new Error(`Invalid schedule: invalid day name, found: ${dayName}`);
}
return Number(dayKey);
};
// Helper function to send setWeeklySchedule command
const sendScheduleCommand = async (entity, dayofweek, numoftrans, transitions, meta) => {
await entity.command("hvacThermostat", "setWeeklySchedule", {
dayofweek,
numoftrans,
mode: 1 << 0, // heat
transitions,
}, utils.getOptions(meta.mapped, entity));
};
const toZigbee = [
// Single/multi day converter with batching support
{
key: days.map((day) => `weekly_schedule_${day}`),
convertSet: async (entity, key, value, meta) => {
utils.assertString(value, key);
// Extract all weekly_schedule keys from the message (if message exists)
const message = meta.message;
const scheduleKeys = message
? Object.keys(message).filter((k) => k.startsWith("weekly_schedule_") && days.includes(k.replace("weekly_schedule_", "")))
: [];
// For single-key messages or when message is not available, process normally (original behavior)
if (scheduleKeys.length <= 1) {