UNPKG

zigbee-herdsman-converters

Version:

Collection of device converters to be used with zigbee-herdsman

914 lines 88.8 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.HueGradientStyle = exports.HueEffectType = exports.fz = exports.hueEffects = exports.gradientScenes = exports.tz = exports.m = exports.manuSpecificPhilips2Fz = exports.knownEffects = void 0; exports.DecodeManuSpecificPhilips2 = DecodeManuSpecificPhilips2; exports.EncodeManuSpecificPhilips2 = EncodeManuSpecificPhilips2; exports.decodeGradientColors = decodeGradientColors; exports.encodeGradientColors = encodeGradientColors; const zigbee_herdsman_1 = require("zigbee-herdsman"); const fz = __importStar(require("../converters/fromZigbee")); const tz = __importStar(require("../converters/toZigbee")); const reporting = __importStar(require("../lib/reporting")); const libColor = __importStar(require("./color")); const color_1 = require("./color"); const exposes = __importStar(require("./exposes")); const light = __importStar(require("./light")); const logger_1 = require("./logger"); const modernExtend = __importStar(require("./modernExtend")); const globalStore = __importStar(require("./store")); const utils = __importStar(require("./utils")); const utils_1 = require("./utils"); const NS = "zhc:philips"; const ea = exposes.access; const e = exposes.presets; const eNumeric = exposes.Numeric; // Gradient color XY scaling constants per Bifrost spec. // MAX_X = 0.7347: maximum X inside the visible light spectrum / Wide Gamut red X. // MAX_Y = 0.8264: outer bound of the Wide Gamut Y axis. // NOTE: many older implementations incorrectly use 0.8431 for MAX_Y. const GRADIENT_COLORS_MAX_X = 0.7347; const GRADIENT_COLORS_MAX_Y = 0.8264; const encodeRGBToScaledGradient = (hex) => { const xy = color_1.ColorRGB.fromHex(hex).toXY(); const x = (xy.x * 4095) / GRADIENT_COLORS_MAX_X; const y = (xy.y * 4095) / GRADIENT_COLORS_MAX_Y; const xx = Math.round(x).toString(16).padStart(3, "0"); const yy = Math.round(y).toString(16).padStart(3, "0"); return [xx[1], xx[2], yy[2], xx[0], yy[0], yy[1]].join(""); }; const decodeScaledGradientToRGB = (p) => { const x = p[3] + p[0] + p[1]; const y = p[4] + p[5] + p[2]; const xx = Number(((Number.parseInt(x, 16) * GRADIENT_COLORS_MAX_X) / 4095).toFixed(4)); const yy = Number(((Number.parseInt(y, 16) * GRADIENT_COLORS_MAX_Y) / 4095).toFixed(4)); return new color_1.ColorXY(xx, yy).toRGB().toHEX(); }; const COLOR_MODE_GRADIENT = "4b01"; const COLOR_MODE_COLOR_XY = "0b00"; const COLOR_MODE_COLOR_TEMP = "0f00"; const COLOR_MODE_EFFECT = "ab00"; const COLOR_MODE_BRIGHTNESS = "0300"; exports.knownEffects = { "0180": "candle", "0280": "fireplace", "0380": "colorloop", "0980": "sunrise", "0a80": "sparkle", "0b80": "opal", "0c80": "glisten", "0d80": "sunset", "0e80": "underwater", "0f80": "cosmos", "1080": "sunbeam", "1180": "enchant", }; const HUE_TAP_LOOKUP = { 34: "press_1", 16: "press_2", 17: "press_3", 18: "press_4", // Actions below are never generated by a Hue Tap but by a PMT 215Z // https://github.com/Koenkk/zigbee2mqtt/issues/18088 98: "press_3_and_4", 99: "release_3_and_4", 100: "press_1_and_2", 101: "release_1_and_2", }; exports.manuSpecificPhilips2Fz = { cluster: "manuSpecificPhilips2", type: ["attributeReport", "readResponse"], convert: (model, msg, publish, options, meta) => { const retval = {}; if (msg.data.state !== undefined) { // Publish the raw, unaltered state blob so advanced clients (e.g. Bifrost) // can perform their own decoding without depending on z2m's interpretation. retval["philips_raw"] = msg.data.state.toString("hex"); const decoded = DecodeManuSpecificPhilips2(msg.data.state); logger_1.logger.debug(`Decoded manuSpecificPhilips2 state: ${JSON.stringify(decoded)}`, NS); if (decoded.onOff !== undefined) { retval["state"] = decoded.onOff ? "ON" : "OFF"; } if (decoded.brightness !== undefined) { retval["brightness"] = decoded.brightness; } if (decoded.colorMirek !== undefined) { retval["color_temp"] = decoded.colorMirek; retval["color_mode"] = "color_temp"; } if (decoded.colorXY !== undefined) { retval["color"] = decoded.colorXY.toObject(); retval["color_mode"] = "xy"; } if (decoded.fadeSpeed !== undefined) { retval["transition"] = decoded.fadeSpeed / 10; } if (decoded.effectType !== undefined) { retval["effect"] = effectNames[decoded.effectType] ?? `unknown_0x${decoded.effectType.toString(16)}`; } if (decoded.effectSpeed !== undefined) { retval["effect_speed"] = decoded.effectSpeed; } if (decoded.gradientColors !== undefined) { // RGB hex for backward compat with z2m frontend and existing automations retval["gradient"] = decoded.gradientColors.colors.map((c) => c.toRGB().toHEX()); // Lossless XY coordinates for clients that need device-independent color retval["gradient_xy"] = decoded.gradientColors.colors.map((c) => c.toObject()); retval["gradient_style"] = gradientStyleNames[decoded.gradientColors.style] ?? "unknown"; } if (decoded.gradientParams !== undefined) { retval["gradient_scale"] = decoded.gradientParams.scale; retval["gradient_offset"] = decoded.gradientParams.offset; } } return retval; }, }; // Keys for Philips2-specific features not handled by standard light converters. const philips2Keys = ["effect_speed", "gradient_scale", "gradient_offset", "gradient_style", "effect_color"]; const philipsModernExtend = { addPhilipsGenBasicCluster: () => modernExtend.deviceAddCustomCluster("genBasic", { name: "genBasic", ID: zigbee_herdsman_1.Zcl.Clusters.genBasic.ID, attributes: { ledIndication: { name: "ledIndication", ID: 0x0033, // 51 type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN, manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, write: true, }, deviceMode: { name: "deviceMode", ID: 0x0034, // 52 type: zigbee_herdsman_1.Zcl.DataType.ENUM8, manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, write: true, }, }, commands: {}, commandsResponse: {}, }), addPhilipsMsOccupancySensingCluster: () => modernExtend.deviceAddCustomCluster("msOccupancySensing", { name: "msOccupancySensing", ID: zigbee_herdsman_1.Zcl.Clusters.msOccupancySensing.ID, attributes: { motionSensitivity: { name: "motionSensitivity", ID: 0x0030, type: zigbee_herdsman_1.Zcl.DataType.UINT8, manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, write: true, }, }, commands: {}, commandsResponse: {}, }), addCustomClusterManuSpecificPhilipsContact: () => modernExtend.deviceAddCustomCluster("manuSpecificPhilipsContact", { name: "manuSpecificPhilipsContact", ID: 0xfc06, manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, attributes: { contact: { name: "contact", ID: 0x0100, type: zigbee_herdsman_1.Zcl.DataType.ENUM8, write: true, max: 0xff }, contactLastChange: { name: "contactLastChange", ID: 0x0101, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff }, tamper: { name: "tamper", ID: 0x0102, type: zigbee_herdsman_1.Zcl.DataType.ENUM8, write: true, max: 0xff }, tamperLastChange: { name: "tamperLastChange", ID: 0x0103, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff }, }, commands: {}, commandsResponse: {}, }), addManuSpecificPhilipsCluster: () => modernExtend.deviceAddCustomCluster("manuSpecificPhilips", { name: "manuSpecificPhilips", ID: 0xfc00, manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, attributes: { config: { name: "config", ID: 0x0031, type: zigbee_herdsman_1.Zcl.DataType.BITMAP16, write: true }, }, commands: {}, commandsResponse: { hueNotification: { name: "hueNotification", ID: 0x00, parameters: [ { name: "button", type: zigbee_herdsman_1.Zcl.DataType.UINT8, max: 0xff }, { name: "unknown1", type: zigbee_herdsman_1.Zcl.DataType.UINT24, max: 0xffffff }, { name: "type", type: zigbee_herdsman_1.Zcl.DataType.UINT8, max: 0xff }, { name: "unknown2", type: zigbee_herdsman_1.Zcl.DataType.UINT8, max: 0xff }, { name: "time", type: zigbee_herdsman_1.Zcl.DataType.UINT8, max: 0xff }, { name: "unknown3", type: zigbee_herdsman_1.Zcl.DataType.UINT8, max: 0xff }, ], }, }, }), addManuSpecificPhilips2Cluster: () => modernExtend.deviceAddCustomCluster("manuSpecificPhilips2", { name: "manuSpecificPhilips2", ID: 0xfc03, manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, attributes: { state: { name: "state", ID: 0x0002, type: zigbee_herdsman_1.Zcl.DataType.OCTET_STR, write: true }, }, commands: { multiColor: { name: "multiColor", ID: 0x00, parameters: [{ name: "data", type: zigbee_herdsman_1.Zcl.BuffaloZclDataType.BUFFER }] }, }, commandsResponse: {}, }), addManuSpecificPhilips3Cluster: () => modernExtend.deviceAddCustomCluster("manuSpecificPhilips3", { name: "manuSpecificPhilips3", ID: 0xfc01, manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, attributes: {}, commands: { command1: { name: "command1", ID: 1, parameters: [{ name: "data", type: zigbee_herdsman_1.Zcl.BuffaloZclDataType.BUFFER }] }, command2: { name: "command2", ID: 2, parameters: [{ name: "data", type: zigbee_herdsman_1.Zcl.BuffaloZclDataType.BUFFER }] }, command3: { name: "command3", ID: 3, parameters: [{ name: "data", type: zigbee_herdsman_1.Zcl.BuffaloZclDataType.BUFFER }] }, command4: { name: "command4", ID: 4, parameters: [{ name: "data", type: zigbee_herdsman_1.Zcl.BuffaloZclDataType.BUFFER }] }, command7: { name: "command7", ID: 7, parameters: [{ name: "data", type: zigbee_herdsman_1.Zcl.BuffaloZclDataType.BUFFER }] }, }, commandsResponse: {}, }), light: (args) => { args = { hueEffect: true, turnsOffAtBrightness1: true, ota: true, ...args }; if (args.hueEffect || args.gradient) args.effect = false; if (args.color) args.color = { modes: ["xy", "hs"], enhancedHue: true, ...((0, utils_1.isObject)(args.color) ? args.color : {}) }; const result = modernExtend.light(args); const toZigbee = result.toZigbee; result.toZigbee = []; // Keys we intercept for Hue native control: core light attributes // that can be sent atomically via the manuSpecificPhilips2 cluster. // Command-only keys (brightness_move, hue_step, etc.) have no Philips2 // equivalent and are left to their standard converters in the array. const nativeKeys = ["state", "brightness", "brightness_percent", "color", "color_temp", "color_temp_percent", "transition"]; const keys = [...nativeKeys, ...philips2Keys]; const philipsLightTz = { key: keys, convertSet: async (entity, key, value, meta) => { // Resolve control mode: explicit option wins; otherwise default to standard converters. const nativeControl = meta.options.hue_native_control === true; // Check if device supports the manuSpecificPhilips2 cluster. // Wrapped in try-catch because supportsInputCluster may throw for // custom clusters not in the cluster registry (e.g. in test mocks). let hasPhilips2Cluster = false; if (utils.isEndpoint(entity)) { try { hasPhilips2Cluster = entity.supportsInputCluster("manuSpecificPhilips2"); } catch { hasPhilips2Cluster = false; } } // Delegate to standard converters if: // - Device doesn't support manuSpecificPhilips2 cluster (old bulbs), OR // - User hasn't opted into native Philips2 control (default) // This mimics Z2M's own per-key dispatch so a single message routes // each key to the appropriate converter. const mustDelegate = (utils.isEndpoint(entity) && !hasPhilips2Cluster) || !nativeControl; if (mustDelegate) { const used = new Set(); let mergedState = {}; // Replicate Z2M's key ordering (publish.ts): when turning off, // state/brightness come first; otherwise they come last. This ensures // standard converters are called in the same sequence as without our // intercepting converter, so e.g. the light turns on before color is // set, and color is set before the light turns off. const messageEntries = Object.entries(meta.message); const stateValue = typeof meta.message.state === "string" ? meta.message.state.toLowerCase() : undefined; const sorter = stateValue === "off" ? 1 : -1; messageEntries.sort((a) => (["state", "brightness", "brightness_percent"].includes(a[0]) ? sorter : sorter * -1)); for (const [msgKey, msgValue] of messageEntries) { // Only delegate keys we claim. Command-only keys (brightness_move, // hue_step, etc.) are NOT in our key list — Z2M routes them to // their standard converters directly. if (!keys.includes(msgKey)) continue; // philips2Keys have no standard equivalent and are handled below. if (philips2Keys.includes(msgKey)) continue; for (const tz of toZigbee) { if (!used.has(tz) && tz.key.includes(msgKey) && tz.convertSet) { used.add(tz); const result = await tz.convertSet(entity, msgKey, msgValue, meta); if (result && "state" in result && result.state) { mergedState = { ...mergedState, ...result.state }; } break; } } } // In delegated mode, we still need to handle Philips2-specific keys (effect_color, // effect_speed, gradient_scale, etc.) below. But if the current call is for a // standard key and no Philips2-specific keys are in the message, we're done. const hasPhilips2Keys = Object.keys(meta.message).some((k) => philips2Keys.includes(k)); if (!hasPhilips2Keys) { return Object.keys(mergedState).length > 0 ? { state: mergedState } : undefined; } // Fall through: continue below to handle Philips2-specific fields only. } // Native control mode (or handling Philips2-specific keys in delegate mode): // build a Philips2 payload from the message. In delegate mode, we filter out // standard keys since the standard converters already sent them. const message = nativeControl ? meta.message : Object.fromEntries(Object.entries(meta.message).filter(([k]) => philips2Keys.includes(k))); const newState = {}; const data = {}; if (message.state !== undefined && typeof message.state === "string") { const msgState = message.state.toLowerCase(); if (["on", "off", "toggle"].includes(msgState) === true) { const targetState = msgState === "toggle" ? (meta.state.state === "ON" ? "off" : "on") : msgState; data.onOff = targetState === "on"; newState.state = data.onOff ? "ON" : "OFF"; } } if (message.brightness != null) { // Bifrost spec: brightness values 0 and 255 are INVALID, valid range 1..254 data.brightness = clamp(Number(message.brightness), 1, 254); } else if (message.brightness_percent != null) { data.brightness = clamp(utils.mapNumberRange(Number(message.brightness_percent), 0, 100, 0, 255), 1, 254); } if (data.brightness !== undefined) { newState.brightness = data.brightness; } if (message.color != null) { const newColor = libColor.Color.fromConverterArg(message.color); if (newColor.isHSV()) { // Convert HSV → RGB → XY instead of silently dropping const xy = newColor.hsv.toRGB().gammaCorrected().toXY().rounded(4); data.colorXY = xy; } else { const xy = newColor.isRGB() ? newColor.rgb.gammaCorrected().toXY().rounded(4) : newColor.xy; data.colorXY = xy; } newState.color_mode = "xy"; newState.color = data.colorXY.toObject(); } if (message.color_temp != null || message.color_temp_percent != null) { const [colorTempMin, colorTempMax] = light.findColorTempRange(entity); const preset = { warmest: colorTempMax, warm: 454, neutral: 370, cool: 250, coolest: colorTempMin }; let colorTemp = message.color_temp; if (message.color_temp_percent != null) { colorTemp = utils .mapNumberRange(Number(message.color_temp_percent), 0, 100, colorTempMin != null ? colorTempMin : 154, colorTempMax != null ? colorTempMax : 500) .toString(); } if (utils.isString(colorTemp) && colorTemp in preset) { data.colorMirek = utils.getFromLookup(colorTemp, preset); } else { data.colorMirek = Number(colorTemp); } newState.color_mode = "color_temp"; newState.color_temp = data.colorMirek; } // Map transition time to Philips2 fadeSpeed // Bifrost spec: 0 = instant, practical range ~2..8, >0x100 = very slow if (message.transition != null) { data.fadeSpeed = Math.round(Number(message.transition) * 10); } // Effect speed: 0.0 = slowest, 1.0 = fastest (maps to 0..255 byte) if (message.effect_speed != null) { data.effectSpeed = clamp(Number(message.effect_speed), 0, 1); } // Gradient scale/offset: fixed-point 5.3 format, exposed as float if (message.gradient_scale != null) { if (data.gradientParams === undefined) { data.gradientParams = { scale: Number(message.gradient_scale), offset: 0 }; } else { data.gradientParams.scale = Number(message.gradient_scale); } } if (message.gradient_offset != null) { if (data.gradientParams === undefined) { data.gradientParams = { scale: 1.0, offset: Number(message.gradient_offset) }; } else { data.gradientParams.offset = Number(message.gradient_offset); } } // Gradient style: stored in state for use with gradient color commands. // When gradient colors are also being sent through this path, the style // is applied directly to data.gradientColors.style. if (message.gradient_style != null) { const styleLookup = { linear: HueGradientStyle.Linear, scattered: HueGradientStyle.Scattered, mirrored: HueGradientStyle.Mirrored, }; const style = styleLookup[String(message.gradient_style).toLowerCase()]; if (style !== undefined) { if (data.gradientColors !== undefined) { data.gradientColors.style = style; } newState.gradient_style = message.gradient_style; } } // When color/color_temp changes without an explicit effect command, // behavior depends on the effect_color_mode option: // - "stop" (default, matches Hue app): color change stops the effect // - "update": color change re-sends the active effect with the new color if ((data.colorXY !== undefined || data.colorMirek !== undefined) && message.effect === undefined) { const effectColorMode = meta.options.effect_color_mode ?? "stop"; const activeEffect = meta.state?.effect; if (effectColorMode === "update" && activeEffect && activeEffect !== "none" && activeEffect in effectLookupAll) { // Re-send the active effect with the new color data.effectType = effectLookupAll[activeEffect]; newState.effect = activeEffect; } else { // Hue app behavior: color change stops effect, clear stale state newState.effect = "none"; } } // Handle effect_color: explicitly set the active effect's base color // without stopping it. If no effect is active, just sets the color. if (message.effect_color != null) { const newColor = libColor.Color.fromConverterArg(message.effect_color); if (newColor.isHSV()) { data.colorXY = newColor.hsv.toRGB().gammaCorrected().toXY().rounded(4); } else { data.colorXY = newColor.isRGB() ? newColor.rgb.gammaCorrected().toXY().rounded(4) : newColor.xy; } newState.color_mode = "xy"; newState.color = data.colorXY.toObject(); // Re-send the active effect with the new color const activeEffect = meta.state?.effect; if (activeEffect && activeEffect !== "none" && activeEffect in effectLookupAll) { data.effectType = effectLookupAll[activeEffect]; newState.effect = activeEffect; } } // When re-sending an effect (via effect_color or "update" mode), // the effect resets brightness on activation. To preserve the user's // brightness, we send it as a separate command AFTER the effect. // Extract brightness now and send it after the main payload. let deferredBrightness; if (data.effectType !== undefined && message.effect === undefined) { if (data.brightness !== undefined) { // User explicitly sent brightness alongside color — defer it deferredBrightness = data.brightness; delete data.brightness; // Keep newState.brightness so state updates correctly } } const encodedPayload = Buffer.from(EncodeManuSpecificPhilips2(data)); // An empty Philips2Data encodes as just 2 zero bytes (the flags header). // Check length rather than all-zeros, since a valid payload could // legitimately contain zero-valued fields. if (encodedPayload.length <= 2) { logger_1.logger.debug("No Philips2 fields to send, falling back to standard converters", NS); // Delegate to the standard converter that handles this key. // Z2M calls convertSet once per key, so exactly one converter matches. for (const tz of toZigbee) { if (tz.key.includes(key)) { return await tz.convertSet(entity, key, value, meta); } } } else { logger_1.logger.debug(`Sending manuSpecificPhilips2 payload: ${encodedPayload.toString("hex")}`, NS); const payload = { data: encodedPayload }; await entity.command("manuSpecificPhilips2", "multiColor", payload); // Send brightness as a separate command after effect activation, // since the effect resets brightness on start. if (deferredBrightness !== undefined) { const brightnessData = { brightness: deferredBrightness }; const brightnessPayload = Buffer.from(EncodeManuSpecificPhilips2(brightnessData)); await entity.command("manuSpecificPhilips2", "multiColor", { data: brightnessPayload, }); newState.brightness = deferredBrightness; } // When an effect is active or being set, read state after a delay // to sync brightness (effects modulate it internally). if (data.effectType !== undefined) { setTimeout(async () => { try { await entity.read("manuSpecificPhilips2", ["state"]); } catch (_e) { // Best-effort sync } }, 1000); } // Merge syncColorState results into newState. syncColorState // returns only color-related keys (color, color_mode, color_temp), // so we spread it on top of newState to keep state, brightness, // effect, etc. intact. const colorState = libColor.syncColorState(newState, meta.state, entity, meta.options); return { state: { ...newState, ...colorState } }; } }, convertGet: async (entity, key, meta) => { let hasPhilips2Cluster = false; if (utils.isEndpoint(entity)) { try { hasPhilips2Cluster = entity.supportsInputCluster("manuSpecificPhilips2"); } catch { hasPhilips2Cluster = false; } } if (utils.isEndpoint(entity) && !hasPhilips2Cluster) { for (const tz of toZigbee) { if (tz.key.includes(key) && tz.convertGet) { return await tz.convertGet(entity, key, meta); } } return; } try { await entity.read("manuSpecificPhilips2", ["state"]); } catch (e) { logger_1.logger.debug(`Reading manuSpecificPhilips2 state failed: ${e}`, NS); } }, options: [ new exposes.Binary("hue_native_control", ea.SET, true, false).withDescription("Control this light using a Philips-specific protocol instead of standard Zigbee commands. " + "When enabled, on/off, brightness, color, and color temperature are combined into single atomic commands. " + "This is required to use the Effect color update mode. " + "When disabled (default), standard Zigbee commands are used, which preserves the usual behavior, " + "including simulating on/off transitions."), new exposes.Enum("effect_color_mode", ea.SET, ["stop", "update"]).withDescription("Controls what happens when color is changed while an effect is active (requires Hue native control). " + "'stop' (default): color change stops the effect (Hue app behavior). " + "'update': color change re-sends the effect with the new color."), ], }; // philipsLightTz claims all standard light keys. Inside convertSet, it delegates // back to the original standard converters by default (opt-out), or sends via // manuSpecificPhilips2 when the user enables the hue_native_control option. // The original standard converters are captured in the `toZigbee` closure above. // Standard converters for keys we DON'T claim. For converters that handle // both claimed and unclaimed keys (e.g. light_onoff_brightness handles "state" // which we claim AND "on_time" which we don't), we create wrappers with only // the unclaimed keys so Z2M doesn't double-dispatch. const unclaimed = []; for (const tz of toZigbee) { const unclaimedKeys = tz.key.filter((k) => !keys.includes(k)); if (unclaimedKeys.length === 0) continue; // We claim all keys of this converter if (unclaimedKeys.length === tz.key.length) { // No overlap — include as-is unclaimed.push(tz); } else { // Partial overlap — wrap with only unclaimed keys unclaimed.push({ ...tz, key: unclaimedKeys }); } } result.toZigbee = [philipsLightTz, philipsTz.hue_power_on_behavior, philipsTz.hue_power_on_error, ...unclaimed]; if (args.hueEffect || args.gradient) { result.toZigbee.push(philipsTz.effect); const effects = ["blink", "breathe", "okay", "channel_change", "candle"]; if (args.color) effects.push("fireplace", "colorloop"); if (args.gradient) { result.toZigbee.push(philipsTz.gradient_scene, philipsTz.gradient({ reverse: true })); if (args.gradient !== true) { effects.push(...args.gradient.extraEffects); } result.exposes.push( // gradient_scene is deprecated, use gradient instead ...(0, utils_1.exposeEndpoints)(e.enum("gradient_scene", ea.SET, Object.keys(exports.gradientScenes)), args.endpointNames), ...(0, utils_1.exposeEndpoints)(e .list("gradient", ea.ALL, e.text("hex", ea.ALL).withDescription("Color in RGB HEX format (eg #663399)")) .withLengthMin(1) .withLengthMax(9) .withDescription("List of RGB HEX colors"), args.endpointNames)); } // Register the Fz converter for all devices (if user enables state reports later) result.fromZigbee.push(exports.manuSpecificPhilips2Fz); // Don't bind or configure reporting automatically - causes unwanted effects // (reports in-between states, conflicting with optimistic states) // https://github.com/Koenkk/zigbee2mqtt/issues/32050#issuecomment-4496461658 // All Hue-specific effects per Bifrost spec effects.push("sunset", "sunrise", "sparkle", "opal", "glisten", "underwater", "cosmos", "sunbeam", "enchant"); effects.push("none", "finish_effect", "stop_effect", "stop_hue_effect"); result.exposes.push(...(0, utils_1.exposeEndpoints)(e.enum("effect", ea.STATE_SET, effects), args.endpointNames)); // Expose effect_speed as a numeric 0..1 (0=slowest, 1=fastest) result.exposes.push(...(0, utils_1.exposeEndpoints)(new eNumeric("effect_speed", ea.STATE_SET) .withValueMin(0) .withValueMax(1) .withValueStep(0.01) .withDescription("Animation speed for the active effect (0=slowest, 1=fastest)"), args.endpointNames)); // Expose effect_color: sets the base color of the active effect // without stopping it. Accepts same formats as color (hex, xy, hs). result.exposes.push(...(0, utils_1.exposeEndpoints)(e .text("effect_color", ea.SET) .withDescription('Set the base color of the active effect without stopping it (hex e.g. #FF4400, or JSON {"x":0.6,"y":0.3})'), args.endpointNames)); if (args.gradient) { // Expose gradient style as an enum (per Bifrost spec: Linear, Scattered, Mirrored) result.exposes.push(...(0, utils_1.exposeEndpoints)(e .enum("gradient_style", ea.ALL, ["linear", "scattered", "mirrored"]) .withDescription("Gradient rendering style: linear (smooth blend), scattered (color per segment), mirrored (symmetric from center)"), args.endpointNames)); // Expose gradient scale and offset as numerics (fixed-point 5.3 format) result.exposes.push(...(0, utils_1.exposeEndpoints)(new eNumeric("gradient_scale", ea.SET) .withValueMin(0) .withValueMax(31) .withValueStep(0.125) .withDescription("Gradient scale (0=auto fit, 1.0+=number of colors visible)"), args.endpointNames), ...(0, utils_1.exposeEndpoints)(new eNumeric("gradient_offset", ea.SET) .withValueMin(0) .withValueMax(31) .withValueStep(0.125) .withDescription("Gradient color offset (0=start from first color)"), args.endpointNames)); } } const customCluster2 = philipsModernExtend.addManuSpecificPhilips2Cluster(); const customCluster3 = philipsModernExtend.addManuSpecificPhilips3Cluster(); result.onEvent = [...customCluster2.onEvent, ...customCluster3.onEvent, ...(result.onEvent ?? [])]; result.configure = [...customCluster2.configure, ...customCluster3.configure, ...(result.configure ?? [])]; return result; }, onOff: (args) => { args = { powerOnBehavior: false, ota: true, ...args }; const result = modernExtend.onOff(args); result.toZigbee.push(philipsTz.hue_power_on_behavior, philipsTz.hue_power_on_error); return result; }, twilightOnOff: () => { const fromZigbee = [fz.ignore_command_on, fz.ignore_command_off, philipsFz.hue_twilight]; const exposes = [ e.action([ "dot_press", "dot_hold", "dot_press_release", "dot_hold_release", "hue_press", "hue_hold", "hue_press_release", "hue_hold_release", ]), ]; const toZigbee = []; const configure = [ async (device, coordinatorEndpoint) => { const endpoint = device.getEndpoint(1); await reporting.bind(endpoint, coordinatorEndpoint, ["genOnOff", "manuSpecificPhilips"]); }, ]; const result = { exposes, fromZigbee, toZigbee, configure, isModernExtend: true }; return result; }, contact: () => { const exposes = [ e.contact().withAccess(ea.STATE_GET), e.tamper().withAccess(ea.STATE_GET), new eNumeric("contact_last_changed", ea.STATE_GET) .withUnit("s") .withDescription("Time (in seconds) since when contact was last changed."), new eNumeric("tamper_last_changed", ea.STATE_GET).withUnit("s").withDescription("Time (in seconds) since when tamper was last changed."), ]; const fromZigbee = [ { cluster: "manuSpecificPhilipsContact", type: ["attributeReport", "readResponse"], convert: (model, msg, publish, options, meta) => { const payload = {}; if (msg.data.contact !== undefined) { // NOTE: 0 = closed, 1 = open payload.contact = msg.data.contact === 0; } if (msg.data.tamper !== undefined) { // NOTE: 0 = OK, 1 = tampered payload.tamper = msg.data.tamper > 0; } if (msg.data.contactLastChange !== undefined) { // NOTE: seems to be 1/10 of a second payload.contact_last_changed = Math.round(msg.data.contactLastChange / 10); } if (msg.data.tamperLastChange !== undefined) { // NOTE: seems to be 1/10 of a second payload.tamper_last_changed = Math.round(msg.data.tamperLastChange / 10); } return payload; }, }, // NOTE: kept for compatibility as there is no auto-reconfigure for modernExtend // this should not fire once reconfigured. { cluster: "genOnOff", type: ["commandOff", "commandOn"], convert: (model, msg, publish, options, meta) => { if (msg.type === "commandOff" || msg.type === "commandOn") { return { contact: msg.type === "commandOff" }; } }, }, ]; const toZigbee = [ { key: ["contact", "tamper", "contact_last_changed", "tamper_last_changed"], convertGet: async (entity, key, meta) => { let attrib = key; switch (key) { case "contact_last_changed": attrib = "contactLastChange"; break; case "tamper_last_changed": attrib = "tamperLastChange"; break; } const ep = (0, utils_1.determineEndpoint)(entity, meta, "manuSpecificPhilipsContact"); try { await ep.read("manuSpecificPhilipsContact", [attrib]); } catch (e) { logger_1.logger.debug(`Reading ${attrib} failed: ${e}, device probably doesn't support it`, "zhc:setupattribute"); } }, }, ]; const configure = [ // NOTE: trigger report after 4 hours incase the network was offline when a contact was triggered // contactLastChange and tamperLastChange seem come with every report of contact, so we do // not configure reporting modernExtend.setupConfigureForReporting("manuSpecificPhilipsContact", "contact", { config: { min: 0, max: "4_HOURS", change: 1 }, access: ea.STATE_GET, singleEndpoint: true, }), modernExtend.setupConfigureForReporting("manuSpecificPhilipsContact", "tamper", { config: { min: 0, max: "4_HOURS", change: 1 }, access: ea.STATE_GET, singleEndpoint: true, }), async (device, coordinatorEndpoint) => { // NOTE: new fromZigbee does not use genOnoff's commandOn/commandOff // so we can unbind genOnOff so the legacy fromZigbee does not // cause double triggers. const endpoint = device.getEndpoint(2); await endpoint.unbind("genOnOff", coordinatorEndpoint); }, ]; const result = { exposes, fromZigbee, toZigbee, configure, isModernExtend: true }; return result; }, }; exports.m = philipsModernExtend; const philipsTz = { gradient_scene: { key: ["gradient_scene"], convertSet: async (entity, key, value, meta) => { const scene = utils.getFromLookup(value, exports.gradientScenes); if (!scene) throw new Error(`Gradient scene '${value}' is unknown`); const payload = { data: Buffer.from(scene, "hex") }; await entity.command("manuSpecificPhilips2", "multiColor", payload); }, }, gradient: (opts = { reverse: false }) => { return { key: ["gradient", "gradient_style"], convertSet: async (entity, key, value, meta) => { // Merge gradient_style from the message into opts if present const mergedOpts = { ...opts }; const { message } = meta; if (message.gradient_style != null) { const styleLookup = { linear: HueGradientStyle.Linear, scattered: HueGradientStyle.Scattered, mirrored: HueGradientStyle.Mirrored, }; const style = styleLookup[String(message.gradient_style).toLowerCase()]; if (style !== undefined) { mergedOpts.style = style; } } // If only gradient_style was sent (no gradient colors), re-send current // gradient from state with the new style let colors = key === "gradient" ? value : message.gradient; if (colors == null && meta.state?.gradient != null) { colors = meta.state.gradient; } if (colors == null || (Array.isArray(colors) && colors.length === 0)) { return; // Nothing to send } // @ts-expect-error ignore const scene = encodeGradientColors(colors, mergedOpts); const payload = { data: Buffer.from(scene, "hex") }; await entity.command("manuSpecificPhilips2", "multiColor", payload); return { state: { gradient_style: message.gradient_style } }; }, convertGet: async (entity, key, meta) => { try { await entity.read("manuSpecificPhilips2", ["state"]); } catch (e) { logger_1.logger.debug(`Reading manuSpecificPhilips2 state for gradient failed: ${e}`, NS); } }, }; }, effect: { key: ["effect"], convertSet: async (entity, key, value, meta) => { utils.assertString(value, "effect"); const lower = value.toLowerCase(); // Stop commands — handle before the generic hueEffects branch, // since stop_hue_effect is in the hueEffects map but not in effectLookupAll. // All three stop variants also send the Hue stop command so they work // regardless of whether the active effect is a ZCL or Hue effect. if (lower === "none" || lower === "stop_hue_effect" || lower === "finish_effect" || lower === "stop_effect") { // Stop Hue-specific effects via manuSpecificPhilips2 await entity.command("manuSpecificPhilips2", "multiColor", { data: Buffer.from(exports.hueEffects.stop_hue_effect, "hex"), }); // Also send the ZCL effect stop for standard effects (blink, breathe, etc.) if (lower === "finish_effect" || lower === "stop_effect") { try { await tz.effect.convertSet(entity, key, value, meta); } catch (_e) { // Ignore — device may not support ZCL identify cluster } } return { state: { effect: "none" } }; } if (lower in effectLookupAll) { // Build payload dynamically so we can include optional color const data = { onOff: true, effectType: effectLookupAll[lower], }; // If color is provided alongside effect, include it in the payload const msg = meta.message; if (msg.color !== undefined) { const newColor = libColor.Color.fromConverterArg(msg.color); if (newColor.isHSV()) { data.colorXY = newColor.hsv.toRGB().gammaCorrected().toXY().rounded(4); } else { data.colorXY = newColor.isRGB() ? newColor.rgb.gammaCorrected().toXY().rounded(4) : newColor.xy; } } // Include effect_speed if provided alongside effect if (msg.effect_speed !== undefined) { data.effectSpeed = clamp(Number(msg.effect_speed), 0, 1); } const payload = { data: Buffer.from(EncodeManuSpecificPhilips2(data)) }; await entity.command("manuSpecificPhilips2", "multiColor", payload); const state = { effect: lower }; if (data.effectSpeed !== undefined) state.effect_speed = data.effectSpeed; // Effects modulate brightness internally (e.g. candle dims to 30-60%). // Read state after a short delay so the Fz converter picks up the // actual brightness the device settled on. setTimeout(async () => { try { await entity.read("manuSpecificPhilips2", ["state"]); } catch (_e) { // Ignore read failures — best-effort sync } }, 1000); return { state }; } // Standard ZCL effects (blink, breathe, okay, channel_change) return await tz.effect.convertSet(entity, key, value, meta); }, }, hue_power_on_behavior: { key: ["hue_power_on_behavior"], convertSet: async (entity, key, value, meta) => { if (value === "default") { value = "on"; } let supports = { colorTemperature: false, colorXY: false }; if (utils.isEndpoint(entity) && entity.supportsInputCluster("lightingColorCtrl")) { const readResult = await entity.read("lightingColorCtrl", ["colorCapa