|
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; |