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