Skip to content

Commit ae932f6

Browse files
neneonlineclaude
andcommitted
Z2M: serve both firmwares from one converter, keep press_action
The multistate presentValue does not mean the same thing on every firmware. Without multi-press, a short press is PRESS (1) and that is the whole signal. With multi-press, 1 is a transient superseded by a confirmed value once the press count is known, so publishing on it fires an action on every tap and then fires again with the real result. Z2M loads one converter for all devices, so a single static mapping cannot serve both: keep 1 and multi-press devices double-fire, drop it and devices on older firmware report nothing at all on a short press. Use numberOfStates to tell them apart — it is already an attribute, and it is 3 without multi-press versus 3 * max_press_count + 4 (7 at minimum) with it. It is read during configure and consulted from the action handler; if it is unavailable the legacy mapping is used, because an extra action is recoverable and a silent button is not. Both mappings share one action vocabulary, so `<switch>_single` means the same gesture on either firmware and the split stays invisible to automations. Also: - Restore the press_action sensor, dropped in the multistate proposal. Its lookup covers the new values. Existing setups are built on it, and it is the only place the transient press/released states remain visible. - Name actions after the endpoint (`switch_0_single`) rather than its ID (`1_single`), matching every other entity on these devices and avoiding the off-by-one where endpoint 1 is switch_0. - confirm_release_ms now stops offering 0, which is the "use the default" sentinel rather than zero delay, and both new settings describe the latency trade-off they control. Regenerating also picks up TS0002-N1J44RTH and TS0003-UWHJGNGJ, which were already in device_db.yaml but missing from the committed converter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1275209 commit ae932f6

4 files changed

Lines changed: 7428 additions & 166 deletions

File tree

helper_scripts/templates/switch_custom.js.jinja

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,13 @@ const romasku = {
114114
endpointNames: [endpointName],
115115
cluster: "genOnOffSwitchCfg",
116116
attribute: { ID: 0xff06, type: 0x21 }, // uint16
117-
description: "Time (ms) to wait after release before confirming press count (single vs double etc.)",
118-
valueMin: 0,
117+
description: "Time (ms) to wait after release before confirming the press count. " +
118+
"Only applies when max_press_count is above 1; a longer window makes double " +
119+
"presses easier to hit but delays every single press by that much. " +
120+
"0 is treated as 'use the default'.",
121+
// 0 is the "use the default" sentinel, not zero delay — offering it
122+
// here would just be a way to silently get 200.
123+
valueMin: 1,
119124
valueMax: 2000,
120125
entityCategory: "config",
121126
}),
@@ -125,11 +130,35 @@ const romasku = {
125130
endpointNames: [endpointName],
126131
cluster: "genOnOffSwitchCfg",
127132
attribute: { ID: 0xff07, type: 0x20 }, // uint8
128-
description: "Maximum number of consecutive presses to differentiate (1=single only, 2=single+double, 3=+triple)",
133+
description: "Maximum number of consecutive presses to differentiate " +
134+
"(1=single only, 2=single+double, 3=+triple). Leave at 1 for the fastest " +
135+
"response: the press is then reported on release with no confirmation wait. " +
136+
"Raising it enables multi-press on this button at the cost of confirm_release_ms " +
137+
"of added delay on every single press.",
129138
valueMin: 1,
130139
valueMax: 3,
131140
entityCategory: "config",
132141
}),
142+
// Raw multistate value as a diagnostic sensor. The `action` above is the
143+
// supported way to drive automations; this stays because it is what existing
144+
// setups are built on, and it is the only place the transient press/released
145+
// states are visible.
146+
pressAction: (name, endpointName) =>
147+
enumLookup({
148+
name,
149+
endpointName,
150+
access: "STATE_GET",
151+
lookup: {
152+
released: 0, press: 1, long_press: 2, position_on: 3, position_off: 4,
153+
single_press: 5, single_release: 6,
154+
double_press: 7, double_hold: 8, double_release: 9,
155+
triple_press: 10, triple_hold: 11, triple_release: 12,
156+
},
157+
cluster: "genMultistateInput",
158+
attribute: "presentValue",
159+
description: "Action of the switch: press/long_press/single_press/double_press/triple_press etc.",
160+
entityCategory: "diagnostic",
161+
}),
133162
relayIndicatorMode: (name, endpointName) =>
134163
enumLookup({
135164
name,
@@ -372,14 +401,53 @@ const romasku = {
372401
}),
373402
};
374403

375-
// Multistate presentValue → action suffix mapping (values 0 and 1 are not published)
376-
const SWITCH_MULTISTATE_ACTION_MAP = {
404+
// Multistate presentValue → action suffix.
405+
//
406+
// The same presentValue means different things depending on the firmware the
407+
// device is running, so there are two maps:
408+
//
409+
// - Firmware without multi-press reports a short press as PRESS (1) and has no
410+
// concept of a confirmed press count. 1 is the whole signal.
411+
// - Firmware with multi-press treats 1 as a transient that is superseded by a
412+
// confirmed value once the press count is known (5 = single, 7 = double, …).
413+
// Publishing on 1 there would fire an action on every tap and then fire
414+
// again with the real result.
415+
//
416+
// Both maps use the same action vocabulary, so an automation written against
417+
// `<switch>_single` works on either firmware and the distinction stays invisible.
418+
const SWITCH_MULTISTATE_ACTION_MAP_LEGACY = {
419+
1: 'single', 2: 'single_hold', 3: 'position_on', 4: 'position_off',
420+
};
421+
const SWITCH_MULTISTATE_ACTION_MAP_MULTI = {
377422
2: 'single_hold', 3: 'position_on', 4: 'position_off',
378423
5: 'single', 6: 'single_release',
379424
7: 'double', 8: 'double_hold', 9: 'double_release',
380425
10: 'triple', 11: 'triple_hold', 12: 'triple_release',
381426
};
382427

428+
// numberOfStates tells the two firmwares apart: firmware without multi-press
429+
// advertises a fixed 3, multi-press firmware advertises 3 * max_press_count + 4,
430+
// which is 7 at its lowest. The gap is narrow — if either number ever changes,
431+
// this threshold has to move with it.
432+
const SWITCH_MULTISTATE_MULTI_MIN_STATES = 7;
433+
434+
function switchMultistateActionSuffix(endpoint, presentValue) {
435+
// Populated by the read in configure(). If it is missing — device not
436+
// configured yet, or the read failed — fall back to the legacy map: an extra
437+
// action is recoverable, a button that reports nothing at all is not.
438+
// Declared return type is number | string | undefined, so coerce before
439+
// comparing. Number(undefined) is NaN and Number(null) is 0; both fail the
440+
// check below and land on the legacy map, which is the safe direction.
441+
const numberOfStates = Number(endpoint.getClusterAttributeValue(
442+
"genMultistateInput", "numberOfStates"));
443+
const isMultiPress = Number.isFinite(numberOfStates) &&
444+
numberOfStates >= SWITCH_MULTISTATE_MULTI_MIN_STATES;
445+
const map = isMultiPress
446+
? SWITCH_MULTISTATE_ACTION_MAP_MULTI
447+
: SWITCH_MULTISTATE_ACTION_MAP_LEGACY;
448+
return map[presentValue];
449+
}
450+
383451
const definitions = [
384452
{% for device in devices %}
385453
{
@@ -447,27 +515,31 @@ const definitions = [
447515
{% if device.switchNames %}
448516
{%- set ns = device.switchNames | length -%}
449517
{%- set action_values = [] -%}
450-
{%- for i in range(1, ns + 1) -%}
518+
{%- for switchName in device.switchNames -%}
451519
{%- for s in ['single', 'single_hold', 'single_release', 'double', 'double_hold', 'double_release', 'triple', 'triple_hold', 'triple_release', 'position_on', 'position_off'] -%}
452-
{%- set _ = action_values.append(i ~ '_' ~ s) -%}
520+
{%- set _ = action_values.append(switchName ~ '_' ~ s) -%}
453521
{%- endfor -%}
454522
{%- endfor -%}
455523
{
456524
fromZigbee: [{
457525
cluster: 'genMultistateInput',
458526
type: ['attributeReport', 'readResponse'],
459527
convert: (model, msg, publish, options, meta) => {
460-
if (msg.endpoint.ID > {{ns}}) return;
461-
const suffix = SWITCH_MULTISTATE_ACTION_MAP[msg.data['presentValue']];
528+
// Endpoints 1..N are the switches, in switchNames order.
529+
const switchNames = {{ device.switchNames | tojson }};
530+
if (msg.endpoint.ID > switchNames.length) return;
531+
const suffix = switchMultistateActionSuffix(
532+
msg.endpoint, msg.data["presentValue"]);
462533
if (suffix === undefined) return;
463-
return {action: `${msg.endpoint.ID}_${suffix}`};
534+
return {action: `${switchNames[msg.endpoint.ID - 1]}_${suffix}`};
464535
},
465536
}],
466537
toZigbee: [],
467538
exposes: [e.action({{ action_values | tojson }})],
468539
},
469540
{% endif %}
470541
{% for switchName in device.switchNames %}
542+
romasku.pressAction("{{switchName}}_press_action", "{{switchName}}"),
471543
romasku.switchMode("{{switchName}}_mode", "{{switchName}}"),
472544
romasku.switchAction("{{switchName}}_action_mode", "{{switchName}}"),
473545
{% if device.relayNames %}
@@ -511,6 +583,10 @@ const definitions = [
511583
{% for switchName in device.switchNames %}
512584
const endpoint{{loop.index}} = device.getEndpoint({{loop.index}});
513585
await reporting.bind(endpoint{{loop.index}}, coordinatorEndpoint, ["genMultistateInput"]);
586+
// Caches numberOfStates, which tells firmware with multi-press support
587+
// apart from firmware without it. Non-fatal: the action handler falls
588+
// back to the legacy mapping when the value is not available.
589+
await endpoint{{loop.index}}.read("genMultistateInput", ["numberOfStates"]).catch(() => {});
514590
// switch action:
515591
await endpoint{{loop.index}}.configureReporting("genMultistateInput", [
516592
{

src/zigbee/switch_cluster.c

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@ const uint8_t multistate_flags = 0;
3030
#define MULTISTATE_N_HOLD(n) ((n) == 1u ? MULTISTATE_LONG_PRESS : (3u * (n) + 2u))
3131
#define MULTISTATE_N_RELEASE(n) ((n) == 1u ? 6u : (3u * (n) + 3u))
3232

33-
// Default timer durations (used when cluster fields are 0)
33+
// Default timer durations (used when cluster fields are 0).
34+
// max_press_count defaults to 1 so an upgrade is behaviour-preserving: with
35+
// max_press_count == 1 there is nothing to disambiguate, so the press is reported
36+
// on release with no confirm wait. Multi-press is opt-in per switch by raising it.
3437
#define DEFAULT_CONFIRM_RELEASE_MS 200u
35-
// Default to 1 so an upgrade is behaviour-preserving: with max_press_count == 1
36-
// there is nothing to disambiguate, so the press is reported on release with no
37-
// confirm wait. Multi-press is opt-in per switch by raising this.
3838
#define DEFAULT_MAX_PRESS_COUNT 1u
3939

4040
extern zigbee_relay_cluster relay_clusters[];

0 commit comments

Comments
 (0)