UNPKG

zigbee-herdsman-converters

Version:

Collection of device converters to be used with zigbee-herdsman

889 lines • 42 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.manufacturerOptions = void 0; exports.ikeaLight = ikeaLight; exports.ikeaBattery = ikeaBattery; exports.ikeaConfigureStyrbar = ikeaConfigureStyrbar; exports.ikeaConfigureRemote = ikeaConfigureRemote; exports.ikeaAirPurifier = ikeaAirPurifier; exports.ikeaVoc = ikeaVoc; exports.ikeaConfigureGenPollCtrl = ikeaConfigureGenPollCtrl; exports.tradfriOccupancy = tradfriOccupancy; exports.tradfriRequestedBrightness = tradfriRequestedBrightness; exports.tradfriCommandsOnOff = tradfriCommandsOnOff; exports.tradfriCommandsLevelCtrl = tradfriCommandsLevelCtrl; exports.styrbarCommandOn = styrbarCommandOn; exports.ikeaDotsClick = ikeaDotsClick; exports.ikeaArrowClick = ikeaArrowClick; exports.ikeaMediaCommands = ikeaMediaCommands; exports.addCustomClusterManuSpecificIkeaAirPurifier = addCustomClusterManuSpecificIkeaAirPurifier; exports.addCustomClusterManuSpecificIkeaVocIndexMeasurement = addCustomClusterManuSpecificIkeaVocIndexMeasurement; exports.addCustomClusterManuSpecificIkeaUnknown = addCustomClusterManuSpecificIkeaUnknown; const semver_1 = require("semver"); const zigbee_herdsman_1 = require("zigbee-herdsman"); const tz = __importStar(require("../converters/toZigbee")); const constants = __importStar(require("../lib/constants")); const exposes_1 = require("../lib/exposes"); const m = __importStar(require("../lib/modernExtend")); const reporting = __importStar(require("../lib/reporting")); const globalStore = __importStar(require("../lib/store")); const utils_1 = require("../lib/utils"); const logger_1 = require("./logger"); const NS = "zhc:ikea"; exports.manufacturerOptions = { manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.IKEA_OF_SWEDEN }; const bulbOnEvent = async (type, data, device, options, state) => { /** * IKEA bulbs lose their configured reportings when losing power. * A deviceAnnounce indicates they are powered on again. * Reconfigure the configured reporting here. * * Additionally some other information is lost like * color_options.execute_if_off. We also restore these. * * NOTE: binds are not lost so rebinding is not needed! */ if (type === "deviceAnnounce") { for (const endpoint of device.endpoints) { for (const c of endpoint.configuredReportings) { await endpoint.configureReporting(c.cluster.name, [ { attribute: c.attribute.name, minimumReportInterval: c.minimumReportInterval, maximumReportInterval: c.maximumReportInterval, reportableChange: c.reportableChange, }, ]); } } // NOTE: execute_if_off default is false // we only restore if true, to save unneeded network writes const colorOptions = state.color_options; if (colorOptions?.execute_if_off === true) { await device.endpoints[0].write("lightingColorCtrl", { options: 1 }); } const levelConfig = state.level_config; if (levelConfig?.execute_if_off === true) { await device.endpoints[0].write("genLevelCtrl", { options: 1 }); } if (levelConfig?.on_level !== undefined) { const onLevelRaw = levelConfig.on_level; let onLevel; if (typeof onLevelRaw === "string" && onLevelRaw.toLowerCase() === "previous") { onLevel = 255; } else { onLevel = Number(onLevelRaw); } if (onLevel > 255) onLevel = 254; if (onLevel < 1) onLevel = 1; await device.endpoints[0].write("genLevelCtrl", { onLevel: onLevel }); } } }; function ikeaLight(args) { const colorTemp = args?.colorTemp ? (args.colorTemp === true ? { range: [250, 454] } : args.colorTemp) : undefined; const levelConfig = args?.levelConfig ? args.levelConfig : { features: ["execute_if_off", "current_level_startup"] }; const result = m.light({ ...args, colorTemp, levelConfig }); result.ota = true; result.onEvent = [bulbOnEvent]; if ((0, utils_1.isObject)(args?.colorTemp) && args.colorTemp.viaColor) { result.toZigbee = (0, utils_1.replaceToZigbeeConvertersInArray)(result.toZigbee, [tz.light_color_colortemp], [tz.light_color_and_colortemp_via_color]); } if (args?.colorTemp || args?.color) { result.exposes.push(exposes_1.presets.light_color_options()); if (result.toZigbee) { // add unfreeze support for color lights result.toZigbee = result.toZigbee.map((orig) => { // As of 2025-04, it looks like all IKEA WS/CWS lights are affected by the freezing bug: const affectedByFreezingBug = true; if (orig.options && affectedByFreezingBug) { const origOptions = orig.options; return { ...orig, options: typeof origOptions === "function" ? (def) => [...origOptions(def), exposes_1.options.unfreeze_support()] : [...origOptions, exposes_1.options.unfreeze_support()], convertSet: trackFreezing(orig.convertSet), }; } return orig; }); } } // Never use a transition when transitioning to OFF as this turns on the light when sending OFF twice // when the bulb has firmware > 1.0.012. // https://github.com/Koenkk/zigbee2mqtt/issues/19211 // https://github.com/Koenkk/zigbee2mqtt/issues/22030#issuecomment-2292063140 // Some old softwareBuildID are not a valid semver, e.g. `1.1.1.0-5.7.2.0` // https://github.com/Koenkk/zigbee2mqtt/issues/23863 result.meta = { ...result.meta, noOffTransitionWhenOff: (entity) => { const softwareBuildID = entity.getDevice().softwareBuildID; return softwareBuildID && (0, semver_1.valid)(softwareBuildID, true) && (0, semver_1.gt)(softwareBuildID, "1.0.012", true); }, }; return result; } function ikeaBattery() { const exposes = [ exposes_1.presets .numeric("battery", exposes_1.access.STATE_GET) .withUnit("%") .withDescription("Remaining battery in %") .withValueMin(0) .withValueMax(100) .withCategory("diagnostic"), ]; const fromZigbee = [ { cluster: "genPowerCfg", type: ["attributeReport", "readResponse"], convert: (model, msg, publish, options, meta) => { const payload = {}; if (msg.data.batteryPercentageRemaining !== undefined && msg.data.batteryPercentageRemaining < 255) { // Some devices do not comply to the ZCL and report a // batteryPercentageRemaining of 100 when the battery is full (should be 200). let dividePercentage = true; if (meta.device.softwareBuildID && (0, semver_1.valid)(meta.device.softwareBuildID, true)) { if (model.model === "E2103") { if ((0, semver_1.lt)(meta.device.softwareBuildID, "24.4.13", true)) { dividePercentage = false; } } else { // IKEA corrected this on newer remote fw version, but many people are still // 2.2.010 which is the last version supporting group bindings. We try to be // smart and pick the correct one for IKEA remotes. // If softwareBuildID is below 2.4.0 it should not be divided if ((0, semver_1.lt)(meta.device.softwareBuildID, "2.4.0", true)) { dividePercentage = false; } } } let percentage = msg.data.batteryPercentageRemaining; percentage = dividePercentage ? percentage / 2 : percentage; payload.battery = (0, utils_1.precisionRound)(percentage, 2); } return payload; }, }, ]; const toZigbee = [ { key: ["battery"], convertGet: async (entity, key, meta) => { await entity.read("genPowerCfg", ["batteryPercentageRemaining"]); }, }, ]; const defaultReporting = { min: "1_HOUR", max: "MAX", change: 10 }; const configure = [ m.setupConfigureForReporting("genPowerCfg", "batteryPercentageRemaining", { config: defaultReporting, access: exposes_1.access.STATE_GET }), (0, utils_1.configureSetPowerSourceWhenUnknown)("Battery"), ]; return { exposes, fromZigbee, toZigbee, configure, isModernExtend: true }; } function ikeaConfigureStyrbar() { const configure = [ async (device, coordinatorEndpoint, definition) => { // https://github.com/Koenkk/zigbee2mqtt/issues/15725 if (device.softwareBuildID && (0, semver_1.valid)(device.softwareBuildID, true) && (0, semver_1.gte)(device.softwareBuildID, "2.4.0", true)) { const endpoint = device.getEndpoint(1); await reporting.bind(endpoint, coordinatorEndpoint, ["genOnOff", "genLevelCtrl", "genScenes"]); } }, ]; return { configure, isModernExtend: true }; } function ikeaConfigureRemote() { const configure = [ async (device, coordinatorEndpoint, definition) => { if (device.softwareBuildID) { // Firmware 2.3.075 >= only supports binding to endpoint, before only to group // - https://github.com/Koenkk/zigbee2mqtt/issues/2772#issuecomment-577389281 // - https://github.com/Koenkk/zigbee2mqtt/issues/7716 const endpoint = device.getEndpoint(1); const version = device.softwareBuildID.split(".").map((n) => Number(n)); const bindTarget = version[0] > 2 || (version[0] === 2 && version[1] > 3) || (version[0] === 2 && version[1] === 3 && version[2] >= 75) ? coordinatorEndpoint : constants.defaultBindGroup; await endpoint.bind("genOnOff", bindTarget); } else { logger_1.logger.warning(`Could not correctly configure '${device.softwareBuildID}' since softwareBuildID is missing, try re-pairing it`, NS); } }, ]; return { configure, isModernExtend: true }; } function ikeaAirPurifier() { const exposes = [ exposes_1.presets.fan().withState("fan_state").withModes(["off", "auto", "1", "2", "3", "4", "5", "6", "7", "8", "9"]), exposes_1.presets.numeric("fan_speed", exposes_1.access.STATE_GET).withValueMin(0).withValueMax(9).withDescription("Current fan speed"), exposes_1.presets .numeric("pm25", exposes_1.access.STATE_GET) .withLabel("PM25") .withUnit("µg/m³") .withDescription("Measured PM2.5 (particulate matter) concentration"), exposes_1.presets .enum("air_quality", exposes_1.access.STATE_GET, ["excellent", "good", "moderate", "poor", "unhealthy", "hazardous", "out_of_range", "unknown"]) .withDescription("Calculated air quality"), exposes_1.presets.binary("led_enable", exposes_1.access.ALL, true, false).withDescription("Controls the LED").withCategory("config"), exposes_1.presets.binary("child_lock", exposes_1.access.ALL, "LOCK", "UNLOCK").withDescription("Controls physical input on the device").withCategory("config"), exposes_1.presets .binary("replace_filter", exposes_1.access.STATE_GET, true, false) .withDescription("Indicates if the filter is older than 6 months and needs replacing") .withCategory("diagnostic"), exposes_1.presets .numeric("filter_age", exposes_1.access.STATE_GET) .withUnit("minutes") .withDescription("Duration the filter has been used") .withCategory("diagnostic"), exposes_1.presets .numeric("device_age", exposes_1.access.STATE_GET) .withUnit("minutes") .withDescription("Duration the air purifier has been used") .withCategory("diagnostic"), ]; const fromZigbee = [ { cluster: "manuSpecificIkeaAirPurifier", type: ["attributeReport", "readResponse"], convert: (model, msg, publish, options, meta) => { const state = {}; if (msg.data.particulateMatter25Measurement !== undefined) { const pm25Property = (0, utils_1.postfixWithEndpointName)("pm25", msg, model, meta); let pm25 = Number.parseFloat(msg.data.particulateMatter25Measurement); // Air Quality // Scale based on EU AQI (https://www.eea.europa.eu/themes/air/air-quality-index) // Using German IAQ labels to match the Develco Air Quality Sensor // biome-ignore lint/suspicious/noImplicitAnyLet: ignored using `--suppress` let airQuality; const airQualityProperty = (0, utils_1.postfixWithEndpointName)("air_quality", msg, model, meta); if (pm25 <= 10) { airQuality = "excellent"; } else if (pm25 <= 20) { airQuality = "good"; } else if (pm25 <= 25) { airQuality = "moderate"; } else if (pm25 <= 50) { airQuality = "poor"; } else if (pm25 <= 75) { airQuality = "unhealthy"; } else if (pm25 <= 800) { airQuality = "hazardous"; } else if (pm25 < 65535) { airQuality = "out_of_range"; } else { airQuality = "unknown"; } pm25 = pm25 === 65535 ? -1 : pm25; state[pm25Property] = pm25; state[airQualityProperty] = airQuality; } if (msg.data.filterRunTime !== undefined) { // Filter needs to be replaced after 6 months state.replace_filter = Number.parseInt(msg.data.filterRunTime) >= 259200; state.filter_age = Number.parseInt(msg.data.filterRunTime); } if (msg.data.deviceRunTime !== undefined) { state.device_age = Number.parseInt(msg.data.deviceRunTime); } if (msg.data.controlPanelLight !== undefined) { state.led_enable = msg.data.controlPanelLight === 0; } if (msg.data.childLock !== undefined) { state.child_lock = msg.data.childLock === 0 ? "UNLOCK" : "LOCK"; } if (msg.data.fanSpeed !== undefined) { let fanSpeed = msg.data.fanSpeed; if (fanSpeed >= 10) { fanSpeed = ((fanSpeed - 5) * 2) / 10; } else { fanSpeed = 0; } state.fan_speed = fanSpeed; } if (msg.data.fanMode !== undefined) { let fanMode = msg.data.fanMode; if (fanMode >= 10) { fanMode = (((fanMode - 5) * 2) / 10).toString(); } else if (fanMode === 1) { fanMode = "auto"; } else { fanMode = "off"; } state.fan_mode = fanMode; state.fan_state = fanMode === "off" ? "OFF" : "ON"; } return state; }, }, ]; const toZigbee = [ { key: ["fan_mode", "fan_state"], convertSet: async (entity, key, value, meta) => { if (key === "fan_state" && typeof value === "string" && value.toLowerCase() === "on") { // biome-ignore lint/style/noParameterAssign: ignored using `--suppress` value = "auto"; } else { // biome-ignore lint/style/noParameterAssign: ignored using `--suppress` value = value.toString().toLowerCase(); } // biome-ignore lint/suspicious/noImplicitAnyLet: ignored using `--suppress` let fanMode; switch (value) { case "off": fanMode = 0; break; case "auto": fanMode = 1; break; default: fanMode = (Number(value) / 2.0) * 10 + 5; } await entity.write("manuSpecificIkeaAirPurifier", { fanMode: fanMode }, exports.manufacturerOptions); return { state: { fan_mode: value, fan_state: value === "off" ? "OFF" : "ON" } }; }, convertGet: async (entity, key, meta) => { await entity.read("manuSpecificIkeaAirPurifier", ["fanMode"]); }, }, { key: ["fan_speed"], convertGet: async (entity, key, meta) => { await entity.read("manuSpecificIkeaAirPurifier", ["fanSpeed"]); }, }, { key: ["pm25", "air_quality"], convertGet: async (entity, key, meta) => { await entity.read("manuSpecificIkeaAirPurifier", ["particulateMatter25Measurement"]); }, }, { key: ["replace_filter", "filter_age"], convertGet: async (entity, key, meta) => { await entity.read("manuSpecificIkeaAirPurifier", ["filterRunTime"]); }, }, { key: ["device_age"], convertGet: async (entity, key, meta) => { await entity.read("manuSpecificIkeaAirPurifier", ["deviceRunTime"]); }, }, { key: ["child_lock"], convertSet: async (entity, key, value, meta) => { (0, utils_1.assertString)(value); await entity.write("manuSpecificIkeaAirPurifier", { childLock: value.toLowerCase() === "unlock" ? 0 : 1 }, exports.manufacturerOptions); return { state: { child_lock: value.toLowerCase() === "lock" ? "LOCK" : "UNLOCK" } }; }, convertGet: async (entity, key, meta) => { await entity.read("manuSpecificIkeaAirPurifier", ["childLock"]); }, }, { key: ["led_enable"], convertSet: async (entity, key, value, meta) => { await entity.write("manuSpecificIkeaAirPurifier", { controlPanelLight: value ? 0 : 1 }, exports.manufacturerOptions); return { state: { led_enable: !!value } }; }, convertGet: async (entity, key, meta) => { await entity.read("manuSpecificIkeaAirPurifier", ["controlPanelLight"]); }, }, ]; const configure = [ async (device, coordinatorEndpoint, definition) => { const endpoint = device.getEndpoint(1); await reporting.bind(endpoint, coordinatorEndpoint, ["manuSpecificIkeaAirPurifier"]); await endpoint.configureReporting("manuSpecificIkeaAirPurifier", [ { attribute: "particulateMatter25Measurement", minimumReportInterval: m.TIME_LOOKUP["1_MINUTE"], maximumReportInterval: m.TIME_LOOKUP["1_HOUR"], reportableChange: 1, }, ], exports.manufacturerOptions); await endpoint.configureReporting("manuSpecificIkeaAirPurifier", [ { attribute: "filterRunTime", minimumReportInterval: m.TIME_LOOKUP["1_HOUR"], maximumReportInterval: m.TIME_LOOKUP["1_HOUR"], reportableChange: 0, }, ], exports.manufacturerOptions); await endpoint.configureReporting("manuSpecificIkeaAirPurifier", [{ attribute: "fanMode", minimumReportInterval: 0, maximumReportInterval: m.TIME_LOOKUP["1_HOUR"], reportableChange: 1 }], exports.manufacturerOptions); await endpoint.configureReporting("manuSpecificIkeaAirPurifier", [{ attribute: "fanSpeed", minimumReportInterval: 0, maximumReportInterval: m.TIME_LOOKUP["1_HOUR"], reportableChange: 1 }], exports.manufacturerOptions); await endpoint.read("manuSpecificIkeaAirPurifier", ["controlPanelLight", "childLock", "filterRunTime"]); }, ]; return { exposes, fromZigbee, toZigbee, configure, isModernExtend: true }; } function ikeaVoc(args) { return m.numeric({ name: "voc_index", label: "VOC index", cluster: "manuSpecificIkeaVocIndexMeasurement", attribute: "measuredValue", reporting: { min: "1_MINUTE", max: "2_MINUTES", change: 1 }, description: "Sensirion VOC index", access: "STATE", ...args, }); } function ikeaConfigureGenPollCtrl(args) { // biome-ignore lint/style/noParameterAssign: ignored using `--suppress` args = { endpointId: 1, ...args }; const configure = [ async (device, coordinatorEndpoint, definition) => { const endpoint = device.getEndpoint(args.endpointId); if (Number(device?.softwareBuildID?.split(".")[0]) >= 24) { await endpoint.write("genPollCtrl", { checkinInterval: 172800 }); } }, ]; return { configure, isModernExtend: true }; } function tradfriOccupancy() { const exposes = [ exposes_1.presets.binary("occupancy", exposes_1.access.STATE, true, false).withDescription("Indicates whether the device detected occupancy"), exposes_1.presets .binary("illuminance_above_threshold", exposes_1.access.STATE, true, false) .withDescription("Indicates whether the device detected bright light (works only in night mode)") .withCategory("diagnostic"), ]; const fromZigbee = [ { cluster: "genOnOff", type: "commandOnWithTimedOff", options: [exposes_1.options.occupancy_timeout(), exposes_1.options.illuminance_below_threshold_check()], convert: (model, msg, publish, options, meta) => { const onlyWhenOnFlag = (msg.data.ctrlbits & 1) !== 0; if (onlyWhenOnFlag && (!options || options.illuminance_below_threshold_check === undefined || options.illuminance_below_threshold_check) && !globalStore.hasValue(msg.endpoint, "timer")) return; const timeout = options?.occupancy_timeout != null ? Number(options.occupancy_timeout) : msg.data.ontime / 10; // Stop existing timer because motion is detected and set a new one. clearTimeout(globalStore.getValue(msg.endpoint, "timer")); globalStore.clearValue(msg.endpoint, "timer"); if (timeout !== 0) { const timer = setTimeout(() => { publish({ occupancy: false }); globalStore.clearValue(msg.endpoint, "timer"); }, timeout * 1000); globalStore.putValue(msg.endpoint, "timer", timer); } return { occupancy: true, illuminance_above_threshold: onlyWhenOnFlag }; }, }, ]; return { exposes, fromZigbee, isModernExtend: true }; } function tradfriRequestedBrightness() { const exposes = [ exposes_1.presets.numeric("requested_brightness_level", exposes_1.access.STATE).withValueMin(76).withValueMax(254).withCategory("diagnostic"), exposes_1.presets.numeric("requested_brightness_percent", exposes_1.access.STATE).withValueMin(30).withValueMax(100).withCategory("diagnostic"), ]; const fromZigbee = [ { // Possible values are 76 (30%) or 254 (100%) cluster: "genLevelCtrl", type: "commandMoveToLevelWithOnOff", convert: (model, msg, publish, options, meta) => { return { requested_brightness_level: msg.data.level, requested_brightness_percent: (0, utils_1.mapNumberRange)(msg.data.level, 0, 254, 0, 100), }; }, }, ]; return { exposes, fromZigbee, isModernExtend: true }; } function tradfriCommandsOnOff() { const exposes = [exposes_1.presets.action(["toggle"])]; const fromZigbee = [ { cluster: "genOnOff", type: "commandToggle", convert: (model, msg, publish, options, meta) => { if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model)) return; return { action: (0, utils_1.postfixWithEndpointName)("toggle", msg, model, meta) }; }, }, ]; return { exposes, fromZigbee, isModernExtend: true }; } function tradfriCommandsLevelCtrl() { const actionLookup = { commandStepWithOnOff: "brightness_up_click", commandStep: "brightness_down_click", commandMoveWithOnOff: "brightness_up_hold", commandStopWithOnOff: "brightness_up_release", commandMove: "brightness_down_hold", commandStop: "brightness_down_release", commandMoveToLevelWithOnOff: "toggle_hold", }; const exposes = [exposes_1.presets.action(Object.values(actionLookup))]; const fromZigbee = [ { cluster: "genLevelCtrl", type: [ "commandStepWithOnOff", "commandStep", "commandMoveWithOnOff", "commandStopWithOnOff", "commandMove", "commandStop", "commandMoveToLevelWithOnOff", ], convert: (model, msg, publish, options, meta) => { if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model)) return; return { action: actionLookup[msg.type] }; }, }, ]; return { exposes, fromZigbee, isModernExtend: true }; } function styrbarCommandOn() { // The STYRBAR sends an on +- 500ms after the arrow release. We don't want to send the ON action in this case. // https://github.com/Koenkk/zigbee2mqtt/issues/13335 const exposes = [exposes_1.presets.action(["on"])]; const fromZigbee = [ { cluster: "genOnOff", type: "commandOn", convert: (model, msg, publish, options, meta) => { if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model)) return; const arrowReleaseAgo = Date.now() - globalStore.getValue(msg.endpoint, "arrow_release", 0); if (arrowReleaseAgo > 700) { return { action: "on" }; } }, }, ]; return { exposes, fromZigbee, isModernExtend: true }; } function ikeaDotsClick(args) { // biome-ignore lint/style/noParameterAssign: ignored using `--suppress` args = { actionLookup: { commandAction1: "initial_press", commandAction2: "long_press", commandAction3: "short_release", commandAction4: "long_release", commandAction6: "double_press", }, dotsPrefix: false, ...args, }; const actions = args.endpointNames.flatMap((b) => Object.values(args.actionLookup).map((a) => (args.dotsPrefix ? `dots_${b}_${a}` : `${b}_${a}`))); const exposes = [exposes_1.presets.action(actions)]; const fromZigbee = [ { // For remotes with firmware 1.0.012 (20211214) cluster: 64639, type: "raw", convert: (model, msg, publish, options, meta) => { if (!Buffer.isBuffer(msg.data)) return; // biome-ignore lint/suspicious/noImplicitAnyLet: ignored using `--suppress` let action; const button = msg.data[5]; switch (msg.data[6]) { case 1: action = "initial_press"; break; case 2: action = "double_press"; break; case 3: action = "long_press"; break; } return { action: args.dotsPrefix ? `dots_${button}_${action}` : `${button}_${action}` }; }, }, { // For remotes with firmware 1.0.32 (20221219) an SOMRIG cluster: "tradfriButton", type: ["commandAction1", "commandAction2", "commandAction3", "commandAction4", "commandAction6"], convert: (model, msg, publish, options, meta) => { const button = (0, utils_1.getEndpointName)(msg, model, meta); const action = (0, utils_1.getFromLookup)(msg.type, args.actionLookup); return { action: args.dotsPrefix ? `dots_${button}_${action}` : `${button}_${action}` }; }, }, ]; const configure = [m.setupConfigureForBinding("tradfriButton", "output", args.endpointNames)]; return { exposes, fromZigbee, configure, isModernExtend: true }; } function ikeaArrowClick(args) { // biome-ignore lint/style/noParameterAssign: ignored using `--suppress` args = { styrbar: false, bind: true, ...args }; const actions = ["arrow_left_click", "arrow_left_hold", "arrow_left_release", "arrow_right_click", "arrow_right_hold", "arrow_right_release"]; const exposes = [exposes_1.presets.action(actions)]; const fromZigbee = [ { cluster: "genScenes", type: "commandTradfriArrowSingle", convert: (model, msg, publish, options, meta) => { if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model)) return; if (msg.data.value === 2) return; // This is send on toggle hold const direction = msg.data.value === 257 ? "left" : "right"; return { action: `arrow_${direction}_click` }; }, }, { cluster: "genScenes", type: "commandTradfriArrowHold", convert: (model, msg, publish, options, meta) => { if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model)) return; const direction = msg.data.value === 3329 ? "left" : "right"; globalStore.putValue(msg.endpoint, "direction", direction); return { action: `arrow_${direction}_hold` }; }, }, { cluster: "genScenes", type: "commandTradfriArrowRelease", convert: (model, msg, publish, options, meta) => { if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model)) return; if (args.styrbar) globalStore.putValue(msg.endpoint, "arrow_release", Date.now()); const direction = globalStore.getValue(msg.endpoint, "direction"); if (direction) { globalStore.clearValue(msg.endpoint, "direction"); const result = { action: `arrow_${direction}_release`, action_duration: msg.data.value / 1000 }; return result; } }, }, ]; const result = { exposes, fromZigbee, isModernExtend: true }; if (args.bind) result.configure = [m.setupConfigureForBinding("genScenes", "output")]; return result; } function ikeaMediaCommands() { const actions = ["track_previous", "track_next", "volume_up", "volume_down", "volume_up_hold", "volume_down_hold"]; const exposes = [exposes_1.presets.action(actions)]; const fromZigbee = [ { cluster: "genLevelCtrl", type: "commandMoveWithOnOff", convert: (model, msg, publish, options, meta) => { if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model)) return; const direction = msg.data.movemode === 1 ? "down" : "up"; return { action: `volume_${direction}` }; }, }, { cluster: "genLevelCtrl", type: "commandMove", convert: (model, msg, publish, options, meta) => { if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model)) return; const direction = msg.data.movemode === 1 ? "down_hold" : "up_hold"; return { action: `volume_${direction}` }; }, }, { cluster: "genLevelCtrl", type: "commandStep", convert: (model, msg, publish, options, meta) => { if ((0, utils_1.hasAlreadyProcessedMessage)(msg, model)) return; const direction = msg.data.stepmode === 1 ? "previous" : "next"; return { action: `track_${direction}` }; }, }, ]; const configure = [m.setupConfigureForBinding("genLevelCtrl", "output")]; return { exposes, fromZigbee, configure, isModernExtend: true }; } function addCustomClusterManuSpecificIkeaAirPurifier() { return m.deviceAddCustomCluster("manuSpecificIkeaAirPurifier", { ID: 0xfc7d, manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.IKEA_OF_SWEDEN, attributes: { filterRunTime: { ID: 0x0000, type: zigbee_herdsman_1.Zcl.DataType.UINT32 }, replaceFilter: { ID: 0x0001, type: zigbee_herdsman_1.Zcl.DataType.UINT8 }, filterLifeTime: { ID: 0x0002, type: zigbee_herdsman_1.Zcl.DataType.UINT32 }, controlPanelLight: { ID: 0x0003, type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN }, particulateMatter25Measurement: { ID: 0x0004, type: zigbee_herdsman_1.Zcl.DataType.UINT16 }, childLock: { ID: 0x0005, type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN }, fanMode: { ID: 0x0006, type: zigbee_herdsman_1.Zcl.DataType.UINT8 }, fanSpeed: { ID: 0x0007, type: zigbee_herdsman_1.Zcl.DataType.UINT8 }, deviceRunTime: { ID: 0x0008, type: zigbee_herdsman_1.Zcl.DataType.UINT32 }, }, commands: {}, commandsResponse: {}, }); } function addCustomClusterManuSpecificIkeaVocIndexMeasurement() { return m.deviceAddCustomCluster("manuSpecificIkeaVocIndexMeasurement", { ID: 0xfc7e, manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.IKEA_OF_SWEDEN, attributes: { measuredValue: { ID: 0x0000, type: zigbee_herdsman_1.Zcl.DataType.SINGLE_PREC }, measuredMinValue: { ID: 0x0001, type: zigbee_herdsman_1.Zcl.DataType.SINGLE_PREC }, measuredMaxValue: { ID: 0x0002, type: zigbee_herdsman_1.Zcl.DataType.SINGLE_PREC }, }, commands: {}, commandsResponse: {}, }); } // Seems to be present on newer IKEA devices like: VINDSTYRKA, RODRET, and BADRING // Also observed on some older devices that had a post DIRIGERA release fw update. // No attributes known. function addCustomClusterManuSpecificIkeaUnknown() { return m.deviceAddCustomCluster("manuSpecificIkeaUnknown", { ID: 0xfc7c, manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.IKEA_OF_SWEDEN, attributes: {}, commands: {}, commandsResponse: {}, }); } const unfreezeMechanisms = { // WS lights: // Aborts the color transition midway: light will stay at the intermediary // state it was when it received the command. // Color lights: // Do not support this command. moveColorTemp: async (entity) => { await entity.command("lightingColorCtrl", "moveColorTemp", { rate: 1, movemode: 0, minimum: 0, maximum: 600 }, {}); }, // WS lights: // Same as "moveColorTemp". // Color lights: // Finishes the color transition instantly: light will instantly // "fast forward" to the final state, post-transition. genLevelCtrl: async (entity) => { await entity.command("genLevelCtrl", "stop", {}, {}); }, }; const STOP_VALUES = ["stop", "0", 0]; // Payloads that can freeze and unfreeze the lights: const COLOR_CHANGE = /(^|_)(color(temp(erature)?)?|hue|saturation)($|_)/; const willFreeze = (key, transition, value) => // Any color change command with a transition will freeze the light... COLOR_CHANGE.test(key) && transition > 0 && // ...except if it's a move/step and we're stopping: !STOP_VALUES.some((stop) => value === stop); const UNFREEZE_DEPENDS_ON_LIGHT = /(color_temp)/; // CWS lights do not support this const UNFREEZE_ALWAYS = /^(brightness_(move|step))|(color_temp_step)$/; const UNFREEZE_WITH_STOP = /^(color_temp_move)$/; const willUnfreeze = (key, value) => { if (UNFREEZE_DEPENDS_ON_LIGHT.test(key)) { return false; // be pessimistic and assume the light won't support it } if (UNFREEZE_ALWAYS.test(key)) { return true; // otherwise those will unfreeze } if (UNFREEZE_WITH_STOP.test(key) && STOP_VALUES.some((stop) => value === stop)) { return true; // and also those, if value matches } return false; }; // Certain IKEA lights will freeze when given a brightness or temperature change with a transition // We track if a light is frozen and if so, before issuing further commands, we send a command known to unfreeze the light // https://github.com/Koenkk/zigbee2mqtt/issues/18574 const trackFreezing = (next) => { const converter = async (entity, key, value, meta) => { if (meta.options.unfreeze_support === false) { return await next(entity, key, value, meta); } const id = "deviceIeeeAddress" in entity ? entity.deviceIeeeAddress : entity.groupID; const now = Date.now(); // unfreeze if necessary before sending the desired commands: const wasFrozenUntil = globalStore.getValue(entity, "frozenUntil"); const isFrozenNow = wasFrozenUntil != null && now <= wasFrozenUntil; const needsUnfreezing = isFrozenNow && !willUnfreeze(key, value); if (needsUnfreezing) { // hardcoded to a single unfreeze mechanism for now: logger_1.logger.debug(`${id}: light frozen until ${new Date(wasFrozenUntil).toISOString()}, unfreezing via "genLevelCtrl"`, NS); await unfreezeMechanisms.genLevelCtrl(entity); } // at this point the light is not frozen, send the desired commands: const ret = await next(entity, key, value, meta); // track if the command has frozen the light: const transition = (0, utils_1.getTransition)(entity, key, meta); if (willFreeze(key, transition.time, value)) { const millis = transition.time * 100; const frozenUntil = Date.now() + millis; logger_1.logger.debug(`${id}: marking light as frozen until ${new Date(frozenUntil).toISOString()} because of "${key}" with transition`, NS); globalStore.putValue(entity, "frozenUntil", frozenUntil); } else if (wasFrozenUntil != null) { logger_1.logger.debug(`${id}: marking light as unfrozen`, NS); globalStore.clearValue(entity, "frozenUntil"); } return ret; }; return converter; }; //# sourceMappingURL=ikea.js.map