Add button.being_held trigger

This commit is contained in:
abmantis
2026-08-02 22:47:25 +01:00
parent 5aca3f27f4
commit 1debf12c29
4 changed files with 88 additions and 22 deletions
@@ -29,6 +29,16 @@
},
"title": "Button",
"triggers": {
"being_held": {
"description": "Triggers repeatedly, at a set interval, while one or more buttons are being held.",
"fields": {
"repeat_interval": {
"description": "How often the trigger repeats while the button is held down.",
"name": "Repeat interval"
}
},
"name": "Button being held"
},
"double_pressed": {
"description": "Triggers when one or more buttons are pressed twice in quick succession.",
"name": "Button double pressed"
@@ -1,7 +1,10 @@
"""Provides triggers for buttons."""
import datetime
from typing import override
import voluptuous as vol
from homeassistant.components.event import (
ATTR_MULTI_PRESS_COUNT,
DOMAIN as EVENT_DOMAIN,
@@ -10,9 +13,13 @@ from homeassistant.components.event import (
EventEntityStateAttribute,
)
from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN
from homeassistant.const import CONF_OPTIONS
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
CONF_REPEAT_INTERVAL,
ENTITY_STATE_TRIGGER_SCHEMA,
NotTriggeredReasonReporter,
StatelessEntityTriggerBase,
Trigger,
@@ -23,6 +30,15 @@ from . import DOMAIN
_EVENT_BUTTON_DOMAIN_SPECS = {
EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.BUTTON)
}
_BEING_HELD_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Required(
CONF_REPEAT_INTERVAL, default=datetime.timedelta(seconds=1)
): cv.positive_time_period
}
}
)
class ButtonPressedTrigger(StatelessEntityTriggerBase):
@@ -104,11 +120,31 @@ class ButtonHoldEndedTrigger(StatelessEntityTriggerBase):
)
class ButtonBeingHeldTrigger(StatelessEntityTriggerBase):
"""Trigger for button event entity being held."""
_domain_specs = _EVENT_BUTTON_DOMAIN_SPECS
_schema = _BEING_HELD_TRIGGER_SCHEMA
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the event is a hold start."""
return (
state.attributes.get(EventEntityStateAttribute.EVENT_TYPE)
== ButtonEventType.LONG_PRESS_START
)
TRIGGERS: dict[str, type[Trigger]] = {
"pressed": ButtonPressedTrigger,
"double_pressed": ButtonDoublePressedTrigger,
"hold_started": ButtonHoldStartedTrigger,
"hold_ended": ButtonHoldEndedTrigger,
"being_held": ButtonBeingHeldTrigger,
}
@@ -16,3 +16,11 @@ hold_started:
target: *event_button_target
hold_ended:
target: *event_button_target
being_held:
target: *event_button_target
fields:
repeat_interval:
required: true
default: 00:00:01
selector:
duration:
+34 -22
View File
@@ -319,6 +319,8 @@ BEHAVIOR_FIRST: Final = "first"
BEHAVIOR_ALL: Final = "all"
BEHAVIOR_EACH: Final = "each"
CONF_REPEAT_INTERVAL: Final = "repeat_interval"
def _create_deprecated_behavior_issue(deprecated: str, replacement: str) -> None:
"""Inform the user a renamed trigger behavior value is still in use."""
@@ -411,6 +413,9 @@ class EntityTriggerBase(Trigger):
assert config.target is not None
self._options = config.options or {}
self._duration: timedelta | None = self._options.get(CONF_FOR)
self._repeat_interval: timedelta | None = self._options.get(
CONF_REPEAT_INTERVAL
)
self._target = config.target
def entity_filter(self, entities: set[str]) -> set[str]:
@@ -507,7 +512,7 @@ class EntityTriggerBase(Trigger):
pending_timers: dict[str, CALLBACK_TYPE],
target_state_change_data: TargetStateChangedData,
) -> None:
"""Cancel pending duration timers invalidated by a state change.
"""Cancel pending timers invalidated by a state change.
Runs on every delivered state change, before the trigger's own
validity checks: an event which cannot fire the trigger, e.g. an
@@ -543,7 +548,7 @@ class EntityTriggerBase(Trigger):
entity_ids: Iterable[str],
states: Mapping[str, State | None],
) -> bool:
"""Check the combined first/all state for a pending duration timer."""
"""Check the combined first/all state for a pending timer."""
matches, included = self.count_matches(entity_ids, states)
if behavior == BEHAVIOR_FIRST:
return matches >= 1
@@ -554,8 +559,9 @@ class EntityTriggerBase(Trigger):
# matches.
return included > 0 and matches == included
# TODO: remove the noqa and sort it out
@override
async def async_attach_runner(
async def async_attach_runner( # noqa: C901
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
@@ -563,8 +569,8 @@ class EntityTriggerBase(Trigger):
"""Attach the trigger to an action runner."""
behavior: str = self._options.get(ATTR_BEHAVIOR, BEHAVIOR_EACH)
# Pending `for:` duration timers, keyed by entity_id for behavior
# each and by the behavior for first/all.
# Pending `for:` duration and repeat interval timers, keyed by
# entity_id for behavior each and by the behavior for first/all.
pending_timers: dict[str, CALLBACK_TYPE] = {}
@callback
@@ -573,7 +579,7 @@ class EntityTriggerBase(Trigger):
removed: set[str],
entity_states: Mapping[str, State | None],
) -> None:
"""Re-validate pending duration timers on target changes.
"""Re-validate pending timers on target changes.
Timers of entities no longer targeted are cancelled, and the
combined first/all condition is recounted over the updated
@@ -652,6 +658,24 @@ class EntityTriggerBase(Trigger):
if matches != 1:
return
@callback
def start_timer(delay: timedelta) -> None:
subscription_key = entity_id if behavior == BEHAVIOR_EACH else behavior
if (
previous_timer := pending_timers.pop(subscription_key, None)
) is not None:
previous_timer()
@callback
def fire_after_duration(_now: datetime) -> None:
"""Fire the action when the timer expires."""
del pending_timers[subscription_key]
call_action()
pending_timers[subscription_key] = async_call_later(
self._hass, delay, fire_after_duration
)
@callback
def call_action() -> None:
"""Call action with right context."""
@@ -665,33 +689,21 @@ class EntityTriggerBase(Trigger):
f"state of {entity_id}",
event.context,
)
if self._repeat_interval:
start_timer(self._repeat_interval)
if not self._duration:
call_action()
return
subscription_key = entity_id if behavior == BEHAVIOR_EACH else behavior
if (
previous_timer := pending_timers.pop(subscription_key, None)
) is not None:
previous_timer()
@callback
def fire_after_duration(_now: datetime) -> None:
"""Fire the action once the state has held for the duration."""
del pending_timers[subscription_key]
call_action()
pending_timers[subscription_key] = async_call_later(
self._hass, self._duration, fire_after_duration
)
start_timer(self._duration)
unsub = await async_track_target_selector_state_change_event(
self._hass,
self._target,
state_change_listener,
self.entity_filter,
handle_entities_update if self._duration else None,
handle_entities_update if self._duration or self._repeat_interval else None,
primary_entities_only=self._primary_entities_only,
)