Skip to content

Instantly share code, notes, and snippets.

@benperiton
Last active November 24, 2025 14:25
Show Gist options
  • Select an option

  • Save benperiton/161507041b33fcb7b437854a3e6abffd to your computer and use it in GitHub Desktop.

Select an option

Save benperiton/161507041b33fcb7b437854a3e6abffd to your computer and use it in GitHub Desktop.
External converter for zigbee2mqtt for Hue Dimmers to support multi-tap

What

Since moving to Z2M, I lost the ability for settings scenes via multi-press. This essentially duplicates what ZHA was doing, and provides some extra actions:

  • on_double_press_release
  • off_double_press_release
  • on_triple_press_release
  • off_triple_press_release
  • on_quadruple_press_release
  • off_quadruple_press_release
  • on_quintuple_press_release
  • off_quintuple_press_release
  • ... etc upto decuple_press_release (10)

How

  • Create the folder if it doesn't exist: /homeassistant/zigbee2mqtt/external_converters
  • Put HueDimmer.mjs in there
  • Restart zigbee2mqtt

Use

Use with this blueprint: https://gist.github.com/benperiton/e58995752b3500e6726ca40472905137

You can configure the multi-press timeout in the Settings (specific) tab on Z2m UI

import {Zcl} from "zigbee-herdsman";
import fz from "zigbee-herdsman-converters/converters/fromZigbee";
import * as exposes from "zigbee-herdsman-converters/lib/exposes";
import * as modernExtend from "zigbee-herdsman-converters/lib/modernExtend";
import * as reporting from "zigbee-herdsman-converters/lib/reporting";
const {presets, access} = exposes;
// Store press queues per device
const pressQueues = new Map();
// Max devices to track (prevents unbounded growth if devices are frequently swapped)
const MAX_TRACKED_DEVICES = 50;
// Default timeout duration in ms (matching ZHA behavior)
const DEFAULT_MULTI_PRESS_TIMEOUT = 350;
// Default multi-press counts (spam-friendly: 5+ presses all become quintuple)
const DEFAULT_MULTI_PRESS_COUNTS = [2, 3, 4, 5];
// All available press count to action name mappings
const PRESS_COUNT_NAMES = {
2: "double_press_release",
3: "triple_press_release",
4: "quadruple_press_release",
5: "quintuple_press_release",
6: "sextuple_press_release",
7: "septuple_press_release",
8: "octuple_press_release",
9: "nonuple_press_release",
10: "decuple_press_release",
};
// Build multi-press actions map from counts array
function buildMultiPressActions(counts) {
const map = new Map();
for (const count of counts) {
if (PRESS_COUNT_NAMES[count]) {
map.set(count, PRESS_COUNT_NAMES[count]);
}
}
return map;
}
// Default multi-press actions (spam-friendly)
const DEFAULT_MULTI_PRESS_ACTIONS = buildMultiPressActions(DEFAULT_MULTI_PRESS_COUNTS);
const fzLocal = {
hue_dimmer_multipress: {
cluster: "manuSpecificPhilips",
type: ["commandHueNotification"],
convert: (model, msg, publish, options, meta) => {
// Get device identifier
const deviceId = msg.device.ieeeAddr;
// Get multi-press timeout from options or use default
const multiPressTimeout = options?.multipress_timeout ?? DEFAULT_MULTI_PRESS_TIMEOUT;
// Get multi-press counts from options or use default
let multiPressActions = DEFAULT_MULTI_PRESS_ACTIONS;
if (options?.multipress_counts) {
// Parse comma-separated list of counts (e.g., "2,3,4,5" or "2,5,10")
const counts = options.multipress_counts
.split(",")
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n) && n >= 2 && n <= 10)
.sort((a, b) => a - b);
if (counts.length > 0) {
multiPressActions = buildMultiPressActions(counts);
}
}
// Initialize press queue for this device if needed (only for on/off buttons)
if (!pressQueues.has(deviceId)) {
// Limit map size to prevent unbounded growth
if (pressQueues.size >= MAX_TRACKED_DEVICES) {
// Remove oldest entry (Maps maintain insertion order)
const firstKey = pressQueues.keys().next().value;
const oldQueues = pressQueues.get(firstKey);
// Clean up any pending timers
if (oldQueues?.on?.timer) clearTimeout(oldQueues.on.timer);
if (oldQueues?.off?.timer) clearTimeout(oldQueues.off.timer);
pressQueues.delete(firstKey);
}
pressQueues.set(deviceId, {
on: {count: 0, timer: null},
off: {count: 0, timer: null},
});
}
const queues = pressQueues.get(deviceId);
// Parse the button event
const button = msg.data.button;
const type = msg.data.type;
// Map button numbers to names
const buttonMap = {
1: "on",
2: "up",
3: "down",
4: "off",
};
const buttonName = buttonMap[button];
if (!buttonName) return;
// Only do multi-press detection for on/off buttons (used for scenes)
// up/down are dimmers, so just pass through normally
const enableMultiPress = buttonName === "on" || buttonName === "off";
// For up/down buttons, use simple pass-through behavior
if (!enableMultiPress) {
if (type === 0) {
return {action: `${buttonName}_press`};
}
if (type === 2) {
return {action: `${buttonName}_press_release`};
}
if (type === 1) {
return {action: `${buttonName}_hold`};
}
if (type === 3) {
return {action: `${buttonName}_hold_release`};
}
return {};
}
// Multi-press logic for on/off buttons only
const queue = queues[buttonName];
// Type 0: Initial press
if (type === 0) {
// Only emit on_press if we're not already in a multi-press sequence
if (!queue.timer) {
return {action: `${buttonName}_press`};
}
return {};
}
// Type 2: Short release - multi-press detection
if (type === 2) {
queue.count++;
// Clear existing timer
if (queue.timer) {
clearTimeout(queue.timer);
}
// Set new timer to detect end of press sequence
queue.timer = setTimeout(() => {
const count = queue.count;
queue.count = 0;
queue.timer = null;
// Emit action based on press count
let action;
if (count === 1) {
// Single press - standard action
action = `${buttonName}_press_release`;
} else {
// Multi-press - find the highest defined count that is <= actual count
const definedCounts = Array.from(multiPressActions.keys()).sort((a, b) => b - a);
const matchingCount = definedCounts.find((c) => c <= count);
const actionSuffix = matchingCount ? multiPressActions.get(matchingCount) : null;
if (actionSuffix) {
action = `${buttonName}_${actionSuffix}`;
}
}
if (action) {
publish({action});
}
}, multiPressTimeout);
// Don't return immediately - wait for timeout
return {};
}
// Type 1: Hold
if (type === 1) {
// Cancel any pending multi-press
if (queue.timer) {
clearTimeout(queue.timer);
queue.timer = null;
queue.count = 0;
}
return {action: `${buttonName}_hold`};
}
// Type 3: Hold release
if (type === 3) {
return {action: `${buttonName}_hold_release`};
}
return {};
},
},
};
const definition = {
zigbeeModel: ["RWL020", "RWL021"],
model: "324131092621",
vendor: "Philips",
description: "Hue dimmer switch",
fromZigbee: [
fz.ignore_command_on,
fz.ignore_command_off_with_effect,
fz.ignore_command_step,
fz.ignore_command_stop,
fzLocal.hue_dimmer_multipress,
fz.battery,
],
toZigbee: [],
configure: async (device, coordinatorEndpoint) => {
const endpoint1 = device.getEndpoint(1);
await reporting.bind(endpoint1, coordinatorEndpoint, ["genOnOff", "genLevelCtrl"]);
const endpoint2 = device.getEndpoint(2);
const options = {manufacturerCode: Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, disableDefaultResponse: true};
await endpoint2.write("genBasic", {49: {value: 0x000b, type: 0x19}}, options);
await reporting.bind(endpoint2, coordinatorEndpoint, ["manuSpecificPhilips", "genPowerCfg"]);
await reporting.batteryPercentageRemaining(endpoint2);
},
endpoint: (device) => {
return {ep1: 1, ep2: 2};
},
extend: [modernExtend.quirkCheckinInterval("1_HOUR")],
ota: true,
exposes: [
presets.battery(),
presets.action([
// Standard actions (all buttons)
"on_press",
"on_press_release",
"on_hold",
"on_hold_release",
"up_press",
"up_press_release",
"up_hold",
"up_hold_release",
"down_press",
"down_press_release",
"down_hold",
"down_hold_release",
"off_press",
"off_press_release",
"off_hold",
"off_hold_release",
// Multi-press release actions (on/off buttons only - for scenes)
// All counts 2-10 exposed for blueprint flexibility
...Object.values(PRESS_COUNT_NAMES).flatMap((suffix) => [
`on_${suffix}`,
`off_${suffix}`,
]),
]),
presets.action_duration(),
],
meta: {battery: {dontDividePercentage: true}},
options: [
exposes.options.simulated_brightness(),
exposes
.numeric("multipress_timeout", access.SET)
.withValueMin(100)
.withValueMax(1000)
.withValueStep(50)
.withUnit("ms")
.withDescription("Timeout window for detecting multi-press sequences (default: 350ms)")
.withPreset("fast", 250, "Fast response (250ms)")
.withPreset("default", 350, "Default (350ms, matches ZHA)")
.withPreset("slow", 500, "Slow response (500ms)"),
exposes
.text("multipress_counts", access.SET)
.withDescription(
"Comma-separated press counts to detect (2-10). " +
"Leave empty for default: 2,3,4,5 (spam-friendly, 5+ → quintuple). " +
"For precision mode use: 2,3,4,5,6,7,8,9,10",
),
],
};
export default definition;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment