Skip to content

Commit d7b9dc5

Browse files
committed
Merge pull request romasku#443 from eakorolev/long-press-endpoint
Long-press companion endpoints # Conflicts: # src/zigbee/switch_cluster.c
2 parents 867a84e + 9209bd2 commit d7b9dc5

25 files changed

Lines changed: 1912 additions & 125 deletions

docs/changelog_fw.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ Please describe what you are working on, under ## Upcoming
1212

1313
### Features
1414

15+
- **Long-press companion endpoints** — new endpoints `2N+1`..`3N` paired with
16+
the existing switch endpoints `1..N`.
17+
- One button can now drive two independent Zigbee bindings: short-press on
18+
`1..N`, long-press on `2N+1`..`3N`.
19+
- LevelControl emit on long-press is configurable: `Move` vs `MoveWithOnOff`,
20+
direction `Up`/`Down`/`Alternate`.
21+
- Safe defaults — nothing happens on long-press until you opt in. A mutex
22+
protects against conflicting configurations between paired endpoints.
23+
- See [endpoints.md](usage/endpoints.md#long-press-as-a-second-binding-target).
1524
- **Cover cluster** (window covering) for controlling the motor of curtains, blinds, and shutters.
1625
Supports open, close, and stop commands with motor safety delays.
1726
- **Cover switch cluster** for handling user input from window covering switches.
@@ -29,6 +38,11 @@ Please describe what you are working on, under ## Upcoming
2938

3039
### Changes
3140

41+
- **Deprecate `binded_mode=LONG` and `relay_mode=LONG` on switch endpoints**
42+
use the new long-press companion endpoint instead. Legacy configurations keep
43+
working unchanged; while a switch endpoint is in either deprecated long mode,
44+
its paired long-press endpoint is muted to prevent double-toggles and
45+
conflicting emissions.
3246
- Add `D<N>` config option to customize button debounce delay in milliseconds
3347
- **Bi-stable (latching) relays** have been reworked
3448
- They now use proper pulses instead of continuously driving the coil

docs/usage/endpoints.md

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@
44
55
As the firmware supports multi-channel (multi-gang) devices, it uses Zigbee endpoints to handle command routing. Zigbee endpoints are numbered, starting from one. For each endpoint, only one instance of a specific function can exist. For example, there can only be a single relay (`OnOffCluster`) attached to endpoint 1. This document explains how the firmware assigns and uses endpoints.
66

7-
If the device is an N-gang switch module, the firmware will use `2 × N` endpoints. The first N endpoints are used for "client" (output) OnOff clusters, which can control other Zigbee devices via direct bindings. The next N endpoints (endpoints N+1 to 2×N) are used for "server" (input) OnOff clusters, which are directly linked to physical relays.
7+
If the device is an N-gang switch module, the firmware uses up to `3 × N` endpoints. The first N endpoints are "client" (output) OnOff clusters for direct bindings. If the device has physical relays, endpoints `N+1`..`2N` are "server" (input) OnOff clusters linked to those relays — on relay-less devices these endpoints are absent. Endpoints `2N+1`..`3N` are long-press companion "client" (output) OnOff + LevelControl clusters that fire on long-press.
88

99
Here is an example table:
1010

11-
| Endpoint | Clusters | Description |
12-
|----------|--------------|----------------------------------------------------------------------------------------------|
13-
| 1 | OnOff client | Binding to control other Zigbee devices |
14-
| ... | OnOff client | ... |
15-
| N | OnOff client | Binding to control other Zigbee devices |
16-
| N+1 | OnOff server | Controls Relay 1 state. Add to a group or bind it with another device to control the relay. |
17-
| ... | OnOff server | ... |
18-
| 2N | OnOff server | Controls Relay N state. Add to a group or bind it with another device to control the relay. |
11+
| Endpoint | Clusters | Description |
12+
|----------|--------------------------------|-------------|
13+
| 1 | OnOff client | Binding to control other Zigbee devices on short press |
14+
| ... | OnOff client | ... |
15+
| N | OnOff client | Binding to control other Zigbee devices |
16+
| N+1 | OnOff server | Controls Relay 1 state. Add to a group or bind it with another device to control the relay. |
17+
| ... | OnOff server | ... |
18+
| 2N | OnOff server | Controls Relay N state. Add to a group or bind it with another device to control the relay. |
19+
| 2N+1 | OnOff client + Level client | Long-press companion for switch 1 |
20+
| ... | OnOff client + Level client | ... |
21+
| 3N | OnOff client + Level client | Long-press companion for switch N |
1922

2023
## Usage Examples
2124

@@ -32,3 +35,19 @@ Bind endpoint 2 of your device to endpoint 1 of the bulb, and bind the `OnOff` c
3235
If you have two 2-gang devices and want to group the first relay of both devices, you should add endpoint 3 of both devices to the same group, as shown in the screenshot:
3336

3437
![add to group](/docs/.images/add_to_group.png)
38+
39+
### Long-press as a second binding target
40+
41+
Bind the `OnOff` cluster of endpoint `2N+1` (long-press companion of button 1) to a Zigbee target. Each long-press sends `Toggle` to that target — independently of any Home Assistant / Zigbee2MQTT automation.
42+
43+
## Migration from legacy long-press configuration
44+
45+
Earlier firmware reused the short-press endpoint for long-press via `binded_mode=LongPress` / `relay_mode=LongPress`. These are deprecated in favor of the dedicated long-press companion endpoints (`2N+1..3N`).
46+
47+
Existing configurations keep working: while a switch endpoint has either of those deprecated modes, its paired long-press companion is muted to prevent double-toggles.
48+
49+
**To migrate a button**:
50+
51+
1. Move any OnOff and LevelControl bindings from the switch endpoint (`1..N`) onto its long-press companion (`2N+1..3N`).
52+
2. If `relay_mode=LongPress` was used on the switch endpoint, set `relay_mode=LongPress` on the companion (and configure `relay_index` if needed).
53+
3. Set the switch endpoint's `binded_mode` and `relay_mode` to anything except `LongPress` — that releases the mute on the companion.

helper_scripts/make_homed_extension.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,30 @@
160160
"endpointId": switch_endpoints
161161
})
162162

163+
long_press_endpoints = list(range(switch_cnt + relay_cnt + cover_cnt + 1,
164+
switch_cnt + relay_cnt + cover_cnt + 1 + switch_cnt))
165+
data[zb_manufacturer].append({
166+
"modelNames": model_names,
167+
"exposes": ["switchAction", "longRelayMode", "relayIndex", "moveCommand", "levelMoveDirection", "levelMoveRate"],
168+
"options": {
169+
"customAttributes": {
170+
"switchAction": {"type": "enum", "clusterId": 0x0007, "attributeId": 0x0010, "dataType": 0x30, "action": True},
171+
"longRelayMode": {"type": "enum", "clusterId": 0x0007, "attributeId": 0xff01, "dataType": 0x30, "action": True},
172+
"relayIndex": {"type": "enum", "clusterId": 0x0007, "attributeId": 0xff02, "dataType": 0x20, "action": True},
173+
"levelMoveRate": {"type": "value", "clusterId": 0x0007, "attributeId": 0xff04, "dataType": 0x20, "action": True},
174+
"levelMoveDirection": {"type": "enum", "clusterId": 0x0007, "attributeId": 0xff08, "dataType": 0x30, "action": True},
175+
"moveCommand": {"type": "enum", "clusterId": 0x0007, "attributeId": 0xff09, "dataType": 0x30, "action": True},
176+
},
177+
"switchAction": {"type": "select", "enum": ["on_off", "off_on", "toggle_simple", "toggle_smart_sync", "toggle_smart_opposite"]},
178+
"longRelayMode": {"type": "select", "enum": {"0": "detached", "2": "long_press"}},
179+
"relayIndex": {"type": "select", "enum": {str(i + 1): f"relay_{i + 1}" for i in range(relay_cnt)}} if relay_cnt else {"type": "value"},
180+
"levelMoveRate": {"type": "number", "min": 1, "max": 255},
181+
"levelMoveDirection": {"type": "select", "enum": {"0": "up", "1": "down", "255": "alternate"}},
182+
"moveCommand": {"type": "select", "enum": {"1": "move", "5": "move_with_on_off"}},
183+
},
184+
"endpointId": long_press_endpoints
185+
})
186+
163187
if indicators_cnt:
164188
relay_indicator_endpoints = list(range(switch_cnt + 1, switch_cnt + 1 + indicators_cnt))
165189
data[zb_manufacturer].append({

helper_scripts/templates/switch_custom.js.jinja

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,36 @@ const romasku = {
107107
valueMax: 255,
108108
entityCategory: "config",
109109
}),
110+
longRelayMode: (name, endpointName) =>
111+
enumLookup({
112+
name,
113+
endpointName,
114+
lookup: { detached: 0, long_press: 2 },
115+
cluster: "genOnOffSwitchCfg",
116+
attribute: { ID: 0xff01, type: 0x30 }, // Enum8
117+
description: "When to toggle the local relay on this long-press endpoint",
118+
entityCategory: "config",
119+
}),
120+
moveCommand: (name, endpointName) =>
121+
enumLookup({
122+
name,
123+
endpointName,
124+
lookup: { move: 1, move_with_on_off: 5 },
125+
cluster: "genOnOffSwitchCfg",
126+
attribute: { ID: 0xff09, type: 0x30 }, // Enum8
127+
description: "Which LevelControl command to emit while held: Move (0x01) or MoveWithOnOff (0x05)",
128+
entityCategory: "config",
129+
}),
130+
levelMoveDirection: (name, endpointName) =>
131+
enumLookup({
132+
name,
133+
endpointName,
134+
lookup: { up: 0, down: 1, alternate: 0xff },
135+
cluster: "genOnOffSwitchCfg",
136+
attribute: { ID: 0xff08, type: 0x30 }, // Enum8
137+
description: "Level move direction while held: up, down, or alternate (flips per long-press)",
138+
entityCategory: "config",
139+
}),
110140
pressAction: (name, endpointName) =>
111141
enumLookup({
112142
name,
@@ -415,6 +445,10 @@ const definitions = [
415445
{%- for coverName in device.coverNames -%}
416446
"{{coverName}}": {{loop.index + (device.switchNames | length) + (device.relayNames | length) + (device.coverSwitchNames | length)}},{{" "}}
417447
{%- endfor -%}
448+
{# Long-press companion endpoints, appended after everything to keep existing endpoint IDs stable. #}
449+
{%- for switchName in device.switchNames -%}
450+
"{{switchName}}_long_press": {{loop.index + (device.switchNames | length) + (device.relayNames | length) + (device.coverSwitchNames | length) + (device.coverNames | length)}},{{" "}}
451+
{%- endfor -%}
418452
} }),
419453
{% set first_endpoint = device.switchNames[0] if device.switchNames else (device.relayNames[0] if device.relayNames else (device.coverSwitchNames[0] if device.coverSwitchNames else device.coverNames[0])) %}
420454
romasku.deviceConfig("device_config", "{{first_endpoint}}"),
@@ -436,6 +470,14 @@ const definitions = [
436470
romasku.bindedMode("{{switchName}}_binded_mode", "{{switchName}}"),
437471
romasku.longPressDuration("{{switchName}}_long_press_duration", "{{switchName}}"),
438472
romasku.levelMoveRate("{{switchName}}_level_move_rate", "{{switchName}}"),
473+
romasku.switchAction("{{switchName}}_long_press_action_mode", "{{switchName}}_long_press"),
474+
romasku.longRelayMode("{{switchName}}_long_press_relay_mode", "{{switchName}}_long_press"),
475+
{% if device.relayNames %}
476+
romasku.relayIndex("{{switchName}}_long_press_relay_index", "{{switchName}}_long_press", {{device.relayNames | length}}),
477+
{% endif %}
478+
romasku.moveCommand("{{switchName}}_long_press_move_command", "{{switchName}}_long_press"),
479+
romasku.levelMoveDirection("{{switchName}}_long_press_level_move_direction", "{{switchName}}_long_press"),
480+
romasku.levelMoveRate("{{switchName}}_long_press_level_move_rate", "{{switchName}}_long_press"),
439481
{% endfor %}
440482
{% for relayName in device.relayIndicatorNames %}
441483
romasku.relayIndicatorMode("{{relayName}}_indicator_mode", "{{relayName}}"),

helper_scripts/templates/zha_quirk.py.jinja

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ class RelayMode(t.enum8):
1414
LongPress = 0x02
1515
ShortPress = 0x03
1616

17+
18+
class LongPressRelayMode(t.enum8):
19+
Detached = 0x00
20+
LongPress = 0x02
21+
1722
class BindedMode(t.enum8):
1823
PressStart = 0x01
1924
LongPress = 0x02
@@ -45,6 +50,17 @@ class CoverSwitchMode(t.enum8):
4550
Hybrid = 0x03
4651

4752

53+
class LevelMoveCommand(t.enum8):
54+
Move = 0x01
55+
MoveWithOnOff = 0x05
56+
57+
58+
class LevelMoveDirection(t.enum8):
59+
Up = 0x00
60+
Down = 0x01
61+
Alternate = 0xff
62+
63+
4864
class CustomOnOffConfigurationCluster(CustomCluster, OnOffConfiguration):
4965

5066
class AttributeDefs(OnOffConfiguration.AttributeDefs):
@@ -92,6 +108,20 @@ class CustomOnOffConfigurationCluster(CustomCluster, OnOffConfiguration):
92108
is_manufacturer_specific=False,
93109
)
94110

111+
level_move_direction = ZCLAttributeDef(
112+
id=0xff08,
113+
type=LevelMoveDirection,
114+
access="rw",
115+
is_manufacturer_specific=False,
116+
)
117+
118+
move_command = ZCLAttributeDef(
119+
id=0xff09,
120+
type=LevelMoveCommand,
121+
access="rw",
122+
is_manufacturer_specific=False,
123+
)
124+
95125

96126
class CustomMultistateInputCluster(CustomCluster, MultistateInput):
97127

@@ -502,4 +532,81 @@ for config in CONFIGS:
502532
)
503533
)
504534

505-
builder.add_to_registry()
535+
long_press_start = switch_cnt + relay_cnt + cover_switch_cnt + cover_cnt + 1
536+
537+
if switch_cnt > 0:
538+
legacy_builder = builder.clone(omit_man_model_data=False)
539+
legacy_builder.filter(
540+
lambda d, ep=long_press_start: ep not in d.endpoints
541+
)
542+
legacy_builder.add_to_registry()
543+
544+
for endpoint_id in range(long_press_start, long_press_start + switch_cnt):
545+
builder = (
546+
builder
547+
.removes(OnOffConfiguration.cluster_id, cluster_type=ClusterType.Client, endpoint_id=endpoint_id)
548+
.adds(CustomOnOffConfigurationCluster, endpoint_id=endpoint_id)
549+
.enum(
550+
CustomOnOffConfigurationCluster.AttributeDefs.switch_actions.name,
551+
SwitchActions,
552+
CustomOnOffConfigurationCluster.cluster_id,
553+
translation_key="long_press_switch_actions_"+str(endpoint_id),
554+
fallback_name="Long press switch actions "+str(endpoint_id),
555+
endpoint_id=endpoint_id,
556+
entity_type=EntityType.CONFIG,
557+
)
558+
.enum(
559+
CustomOnOffConfigurationCluster.AttributeDefs.relay_mode.name,
560+
LongPressRelayMode,
561+
CustomOnOffConfigurationCluster.cluster_id,
562+
translation_key="long_press_relay_mode_"+str(endpoint_id),
563+
fallback_name="Long press relay mode "+str(endpoint_id),
564+
endpoint_id=endpoint_id,
565+
entity_type=EntityType.CONFIG,
566+
)
567+
.number(
568+
CustomOnOffConfigurationCluster.AttributeDefs.relay_index.name,
569+
CustomOnOffConfigurationCluster.cluster_id,
570+
translation_key="long_press_relay_index_"+str(endpoint_id),
571+
fallback_name="Long press relay index "+str(endpoint_id),
572+
min_value=1,
573+
max_value=relay_cnt,
574+
step=1,
575+
endpoint_id=endpoint_id,
576+
entity_type=EntityType.CONFIG,
577+
)
578+
.enum(
579+
CustomOnOffConfigurationCluster.AttributeDefs.move_command.name,
580+
LevelMoveCommand,
581+
CustomOnOffConfigurationCluster.cluster_id,
582+
translation_key="long_press_move_command_"+str(endpoint_id),
583+
fallback_name="Long press move command "+str(endpoint_id),
584+
endpoint_id=endpoint_id,
585+
entity_type=EntityType.CONFIG,
586+
)
587+
.enum(
588+
CustomOnOffConfigurationCluster.AttributeDefs.level_move_direction.name,
589+
LevelMoveDirection,
590+
CustomOnOffConfigurationCluster.cluster_id,
591+
translation_key="long_press_level_move_direction_"+str(endpoint_id),
592+
fallback_name="Long press level move direction "+str(endpoint_id),
593+
endpoint_id=endpoint_id,
594+
entity_type=EntityType.CONFIG,
595+
)
596+
.number(
597+
CustomOnOffConfigurationCluster.AttributeDefs.level_move_rate.name,
598+
CustomOnOffConfigurationCluster.cluster_id,
599+
translation_key="long_press_level_move_rate_"+str(endpoint_id),
600+
fallback_name="Long press level move rate "+str(endpoint_id),
601+
min_value=1,
602+
max_value=255,
603+
step=1,
604+
endpoint_id=endpoint_id,
605+
entity_type=EntityType.CONFIG,
606+
)
607+
)
608+
609+
builder.filter(lambda d, ep=long_press_start: ep in d.endpoints)
610+
builder.add_to_registry()
611+
else:
612+
builder.add_to_registry()

0 commit comments

Comments
 (0)