zigbee-herdsman-converters
Version:
Collection of device converters to be used with zigbee-herdsman
889 lines • 42 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.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