Compare commits

..

22 Commits

Author SHA1 Message Date
abmantis
6027b697c9 Fix return type 2025-10-06 22:13:22 +01:00
abmantis
7a86007083 Add tests for different action type 2025-10-06 22:08:46 +01:00
abmantis
a6e21a54a3 Wrap action so it is always a coroutine function 2025-10-06 22:08:01 +01:00
abmantis
8b3cb69b79 Merge branch 'dev' of github.com:home-assistant/core into trigger_action_ux 2025-10-06 18:49:51 +01:00
William Scanlon
75e900606e Update water heater max temperature (#150970) 2025-10-06 19:21:21 +02:00
abmantis
f296a215e7 Use hass from init 2025-10-02 17:50:34 +01:00
abmantis
4b5fd38849 Merge branch 'dev' of github.com:home-assistant/core into trigger_action_ux 2025-10-02 17:25:00 +01:00
abmantis
308f6eb5a8 Improve doc 2025-10-02 16:22:17 +01:00
abmantis
82f1ae3519 Rename class; add doc 2025-10-02 16:19:41 +01:00
abmantis
b8660b4248 Rename callback type 2025-10-01 22:12:51 +01:00
abmantis
fba50af1c3 Replace wrapper with builder method 2025-10-01 22:04:34 +01:00
abmantis
bdd448fbe0 Allow overriding trigger runner helper 2025-10-01 16:24:50 +01:00
abmantis
c3f45d594b Return future from runner 2025-09-28 12:59:16 +01:00
Abílio Costa
a95af1a40e Merge branch 'dev' into trigger_action_ux 2025-09-27 15:08:54 +01:00
abmantis
fa863649fa Merge branch 'trigger_action_ux' of github.com:home-assistant/core into trigger_action_ux 2025-09-25 15:41:20 +01:00
abmantis
b7c6e21707 Merge branch 'dev' of github.com:home-assistant/core into trigger_action_ux 2025-09-25 15:13:55 +01:00
Abílio Costa
e7da1250ba Merge branch 'dev' into trigger_action_ux 2025-09-24 22:01:19 +01:00
abmantis
e71140e09b Fix typing 2025-09-24 17:11:45 +01:00
abmantis
53875f7188 Fix zwavejs device_trigger 2025-09-24 14:39:46 +01:00
abmantis
526541f666 Merge branch 'dev' of github.com:home-assistant/core into trigger_action_ux 2025-09-24 14:37:52 +01:00
abmantis
01d81f8980 Move attach_trigger out of Trigger class 2025-09-23 16:30:22 +01:00
abmantis
7d96a814f9 Simplify firing of trigger actions 2025-09-22 19:16:30 +01:00
7 changed files with 236 additions and 115 deletions

View File

@@ -20,8 +20,9 @@ set_temperature:
selector:
number:
min: 0
max: 100
max: 250
step: 0.5
mode: box
unit_of_measurement: "°"
operation_mode:
example: eco

View File

@@ -20,7 +20,7 @@ from homeassistant.const import (
CONF_PLATFORM,
CONF_TYPE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.core import CALLBACK_TYPE, Context, HassJob, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
@@ -454,8 +454,30 @@ async def async_attach_trigger(
zwave_js_config = await validate_value_updated_trigger_config(
hass, zwave_js_config
)
job = HassJob(action)
@callback
def run_action(
description: str,
extra_trigger_payload: dict[str, Any],
context: Context | None = None,
) -> None:
"""Run action with trigger variables."""
payload = {
"trigger": {
**trigger_info["trigger_data"],
CONF_PLATFORM: VALUE_UPDATED_PLATFORM_TYPE,
"description": description,
**extra_trigger_payload,
}
}
hass.async_run_hass_job(job, payload, context)
return await attach_value_updated_trigger(
hass, zwave_js_config[CONF_OPTIONS], action, trigger_info
hass, zwave_js_config[CONF_OPTIONS], run_action
)
raise HomeAssistantError(f"Unhandled trigger type {trigger_type}")

View File

@@ -17,19 +17,12 @@ from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_PLATFORM,
)
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.trigger import (
Trigger,
TriggerActionType,
TriggerConfig,
TriggerData,
TriggerInfo,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from ..const import (
@@ -127,17 +120,13 @@ _CONFIG_SCHEMA = vol.Schema(
class EventTrigger(Trigger):
"""Z-Wave JS event trigger."""
_hass: HomeAssistant
_options: dict[str, Any]
_event_source: str
_event_name: str
_event_data_filter: dict
_job: HassJob
_trigger_data: TriggerData
_unsubs: list[Callable]
_platform_type = PLATFORM_TYPE
_action_runner: TriggerActionRunner
@classmethod
async def async_validate_complete_config(
@@ -176,14 +165,12 @@ class EventTrigger(Trigger):
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
self._hass = hass
super().__init__(hass, config)
assert config.options is not None
self._options = config.options
async def async_attach(
self,
action: TriggerActionType,
trigger_info: TriggerInfo,
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach a trigger."""
dev_reg = dr.async_get(self._hass)
@@ -198,8 +185,7 @@ class EventTrigger(Trigger):
self._event_source = options[ATTR_EVENT_SOURCE]
self._event_name = options[ATTR_EVENT]
self._event_data_filter = options.get(ATTR_EVENT_DATA, {})
self._job = HassJob(action)
self._trigger_data = trigger_info["trigger_data"]
self._action_runner = run_action
self._unsubs: list[Callable] = []
self._create_zwave_listeners()
@@ -225,9 +211,7 @@ class EventTrigger(Trigger):
if event_data[key] != val:
return
payload = {
**self._trigger_data,
CONF_PLATFORM: self._platform_type,
payload: dict[str, Any] = {
ATTR_EVENT_SOURCE: self._event_source,
ATTR_EVENT: self._event_name,
ATTR_EVENT_DATA: event_data,
@@ -237,21 +221,17 @@ class EventTrigger(Trigger):
f"Z-Wave JS '{self._event_source}' event '{self._event_name}' was emitted"
)
description = primary_desc
if device:
device_name = device.name_by_user or device.name
payload[ATTR_DEVICE_ID] = device.id
home_and_node_id = get_home_and_node_id_from_device_entry(device)
assert home_and_node_id
payload[ATTR_NODE_ID] = home_and_node_id[1]
payload["description"] = f"{primary_desc} on {device_name}"
else:
payload["description"] = primary_desc
description = f"{primary_desc} on {device_name}"
payload["description"] = (
f"{payload['description']} with event data: {event_data}"
)
self._hass.async_run_hass_job(self._job, {"trigger": payload})
description = f"{description} with event data: {event_data}"
self._action_runner(description, payload)
@callback
def _async_remove(self) -> None:

View File

@@ -11,23 +11,12 @@ from zwave_js_server.const import CommandClass
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.value import Value, get_value_id_str
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_PLATFORM,
MATCH_ALL,
)
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_OPTIONS, MATCH_ALL
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.trigger import (
Trigger,
TriggerActionType,
TriggerConfig,
TriggerInfo,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from ..config_validation import VALUE_SCHEMA
@@ -100,12 +89,7 @@ async def async_validate_trigger_config(
async def async_attach_trigger(
hass: HomeAssistant,
options: ConfigType,
action: TriggerActionType,
trigger_info: TriggerInfo,
*,
platform_type: str = PLATFORM_TYPE,
hass: HomeAssistant, options: ConfigType, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
dev_reg = dr.async_get(hass)
@@ -121,9 +105,6 @@ async def async_attach_trigger(
endpoint = options.get(ATTR_ENDPOINT)
property_key = options.get(ATTR_PROPERTY_KEY)
unsubs: list[Callable] = []
job = HassJob(action)
trigger_data = trigger_info["trigger_data"]
@callback
def async_on_value_updated(
@@ -152,10 +133,8 @@ async def async_attach_trigger(
return
device_name = device.name_by_user or device.name
description = f"Z-Wave value {value.value_id} updated on {device_name}"
payload = {
**trigger_data,
CONF_PLATFORM: platform_type,
ATTR_DEVICE_ID: device.id,
ATTR_NODE_ID: value.node.node_id,
ATTR_COMMAND_CLASS: value.command_class,
@@ -169,10 +148,9 @@ async def async_attach_trigger(
ATTR_PREVIOUS_VALUE_RAW: prev_value_raw,
ATTR_CURRENT_VALUE: curr_value,
ATTR_CURRENT_VALUE_RAW: curr_value_raw,
"description": f"Z-Wave value {value.value_id} updated on {device_name}",
}
hass.async_run_hass_job(job, {"trigger": payload})
run_action(description, payload)
@callback
def async_remove() -> None:
@@ -223,7 +201,6 @@ async def async_attach_trigger(
class ValueUpdatedTrigger(Trigger):
"""Z-Wave JS value updated trigger."""
_hass: HomeAssistant
_options: dict[str, Any]
@classmethod
@@ -245,16 +222,12 @@ class ValueUpdatedTrigger(Trigger):
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
self._hass = hass
super().__init__(hass, config)
assert config.options is not None
self._options = config.options
async def async_attach(
self,
action: TriggerActionType,
trigger_info: TriggerInfo,
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach a trigger."""
return await async_attach_trigger(
self._hass, self._options, action, trigger_info
)
return await async_attach_trigger(self._hass, self._options, run_action)

View File

@@ -28,8 +28,10 @@ from homeassistant.core import (
CALLBACK_TYPE,
Context,
HassJob,
HassJobType,
HomeAssistant,
callback,
get_hassjob_callable_job_type,
is_callback,
)
from homeassistant.exceptions import HomeAssistantError, TemplateError
@@ -178,6 +180,8 @@ _TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
class Trigger(abc.ABC):
"""Trigger class."""
_hass: HomeAssistant
@classmethod
async def async_validate_complete_config(
cls, hass: HomeAssistant, complete_config: ConfigType
@@ -212,14 +216,33 @@ class Trigger(abc.ABC):
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
self._hass = hass
async def async_attach_action(
self,
action: Callable[[dict[str, Any], Context | None], Coroutine[Any, Any, Any]],
action_payload_builder: TriggerActionPayloadBuilder,
) -> CALLBACK_TYPE:
"""Attach the trigger to an action."""
@callback
def run_action(
description: str,
extra_trigger_payload: dict[str, Any],
context: Context | None = None,
) -> asyncio.Task[Any]:
"""Run action with trigger variables."""
payload = action_payload_builder(description, extra_trigger_payload)
return self._hass.async_create_task(action(payload, context))
return await self.async_attach_runner(run_action)
@abc.abstractmethod
async def async_attach(
self,
action: TriggerActionType,
trigger_info: TriggerInfo,
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger."""
"""Attach the trigger to an action runner."""
class TriggerProtocol(Protocol):
@@ -257,6 +280,32 @@ class TriggerConfig:
options: dict[str, Any] | None = None
class TriggerActionRunner(Protocol):
"""Protocol type for the trigger action runner helper callback."""
@callback
def __call__(
self,
description: str,
extra_trigger_payload: dict[str, Any],
context: Context | None = None,
) -> asyncio.Task[Any]:
"""Define trigger action runner type.
Returns:
A Task that allows awaiting for the action to finish.
"""
class TriggerActionPayloadBuilder(Protocol):
"""Protocol type for the trigger action payload builder."""
def __call__(
self, description: str, extra_trigger_payload: dict[str, Any]
) -> dict[str, Any]:
"""Define trigger action payload builder type."""
class TriggerActionType(Protocol):
"""Protocol type for trigger action callback."""
@@ -493,6 +542,71 @@ def _trigger_action_wrapper(
return wrapper_func
async def _async_attach_trigger_cls(
hass: HomeAssistant,
trigger_cls: type[Trigger],
trigger_key: str,
conf: ConfigType,
action: Callable,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Initialize a new Trigger class and attach it."""
def action_payload_builder(
description: str, extra_trigger_payload: dict[str, Any]
) -> dict[str, Any]:
"""Build action variables."""
payload = {
"trigger": {
**trigger_info["trigger_data"],
CONF_PLATFORM: trigger_key,
"description": description,
**extra_trigger_payload,
}
}
if CONF_VARIABLES in conf:
trigger_variables = conf[CONF_VARIABLES]
payload.update(trigger_variables.async_render(hass, payload))
return payload
# Wrap sync action so that it is always async.
# This should be removed when sync actions are no longer supported.
match get_hassjob_callable_job_type(action):
case HassJobType.Executor:
original_action = action
async def wrapped_executor_action(
run_variables: dict[str, Any], context: Context | None = None
) -> Any:
"""Wrap sync action to be called in executor."""
return await hass.async_add_executor_job(
original_action, run_variables, context
)
action = wrapped_executor_action
case HassJobType.Callback:
original_action = action
async def wrapped_callback_action(
run_variables: dict[str, Any], context: Context | None = None
) -> Any:
"""Wrap callback action to be awaitable."""
return original_action(run_variables, context)
action = wrapped_callback_action
trigger = trigger_cls(
hass,
TriggerConfig(
key=trigger_key,
target=conf.get(CONF_TARGET),
options=conf.get(CONF_OPTIONS),
),
)
return await trigger.async_attach_action(action, action_payload_builder)
async def async_initialize_triggers(
hass: HomeAssistant,
trigger_config: list[ConfigType],
@@ -532,23 +646,17 @@ async def async_initialize_triggers(
trigger_data=trigger_data,
)
action_wrapper = _trigger_action_wrapper(hass, action, conf)
if hasattr(platform, "async_get_triggers"):
trigger_descriptors = await platform.async_get_triggers(hass)
relative_trigger_key = get_relative_description_key(
platform_domain, trigger_key
)
trigger_cls = trigger_descriptors[relative_trigger_key]
trigger = trigger_cls(
hass,
TriggerConfig(
key=trigger_key,
target=conf.get(CONF_TARGET),
options=conf.get(CONF_OPTIONS),
),
coro = _async_attach_trigger_cls(
hass, trigger_cls, trigger_key, conf, action, info
)
coro = trigger.async_attach(action_wrapper, info)
else:
action_wrapper = _trigger_action_wrapper(hass, action, conf)
coro = platform.async_attach_trigger(hass, conf, action_wrapper, info)
triggers.append(create_eager_task(coro))

View File

@@ -5,6 +5,9 @@ build_from:
armhf: "ghcr.io/home-assistant/armhf-homeassistant:"
amd64: "ghcr.io/home-assistant/amd64-homeassistant:"
i386: "ghcr.io/home-assistant/i386-homeassistant:"
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
cosign:
base_identity: https://github.com/home-assistant/core/.*
identity: https://github.com/home-assistant/core/.*

View File

@@ -24,9 +24,7 @@ from homeassistant.helpers.trigger import (
DATA_PLUGGABLE_ACTIONS,
PluggableAction,
Trigger,
TriggerActionType,
TriggerConfig,
TriggerInfo,
TriggerActionRunner,
_async_get_trigger_platform,
async_initialize_triggers,
async_validate_trigger_config,
@@ -449,7 +447,31 @@ async def test_pluggable_action(
assert not plug_2
async def test_platform_multiple_triggers(hass: HomeAssistant) -> None:
class TriggerActionFunctionTypeHelper:
"""Helper for testing different trigger action function types."""
def __init__(self) -> None:
"""Init helper."""
self.action_calls = []
@callback
def cb_action(self, *args):
"""Callback action."""
self.action_calls.append([*args])
def sync_action(self, *args):
"""Sync action."""
self.action_calls.append([*args])
async def async_action(self, *args):
"""Async action."""
self.action_calls.append([*args])
@pytest.mark.parametrize("action_method", ["cb_action", "sync_action", "async_action"])
async def test_platform_multiple_triggers(
hass: HomeAssistant, action_method: str
) -> None:
"""Test a trigger platform with multiple trigger."""
class MockTrigger(Trigger):
@@ -462,30 +484,23 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None:
"""Validate config."""
return config
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
class MockTrigger1(MockTrigger):
"""Mock trigger 1."""
async def async_attach(
self,
action: TriggerActionType,
trigger_info: TriggerInfo,
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach a trigger."""
action({"trigger": "test_trigger_1"})
run_action("trigger 1 desc", {"extra": "test_trigger_1"})
class MockTrigger2(MockTrigger):
"""Mock trigger 2."""
async def async_attach(
self,
action: TriggerActionType,
trigger_info: TriggerInfo,
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach a trigger."""
action({"trigger": "test_trigger_2"})
run_action("trigger 2 desc", {"extra": "test_trigger_2"})
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {
@@ -508,22 +523,41 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None:
log_cb = MagicMock()
action_calls = []
action_helper = TriggerActionFunctionTypeHelper()
action_method = getattr(action_helper, action_method)
@callback
def cb_action(*args):
action_calls.append([*args])
await async_initialize_triggers(hass, config_1, action_method, "test", "", log_cb)
assert len(action_helper.action_calls) == 1
assert action_helper.action_calls[0][0] == {
"trigger": {
"alias": None,
"description": "trigger 1 desc",
"extra": "test_trigger_1",
"id": "0",
"idx": "0",
"platform": "test",
}
}
action_helper.action_calls.clear()
await async_initialize_triggers(hass, config_1, cb_action, "test", "", log_cb)
assert action_calls == [[{"trigger": "test_trigger_1"}]]
action_calls.clear()
await async_initialize_triggers(hass, config_2, cb_action, "test", "", log_cb)
assert action_calls == [[{"trigger": "test_trigger_2"}]]
action_calls.clear()
await async_initialize_triggers(hass, config_2, action_method, "test", "", log_cb)
assert len(action_helper.action_calls) == 1
assert action_helper.action_calls[0][0] == {
"trigger": {
"alias": None,
"description": "trigger 2 desc",
"extra": "test_trigger_2",
"id": "0",
"idx": "0",
"platform": "test.trig_2",
}
}
action_helper.action_calls.clear()
with pytest.raises(KeyError):
await async_initialize_triggers(hass, config_3, cb_action, "test", "", log_cb)
await async_initialize_triggers(
hass, config_3, action_method, "test", "", log_cb
)
async def test_platform_migrate_trigger(hass: HomeAssistant) -> None: