UNPKG

zigbee-herdsman-converters

Version:

Collection of device converters to be used with zigbee-herdsman

960 lines (959 loc) 416 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.definitions = void 0; const 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) {