From 9da9eaf338ebd358f8fbeae2f6a3a22ec9047ac9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Mar 2026 14:38:07 +0100 Subject: [PATCH] Add power triggers (#166253) --- CODEOWNERS | 2 + homeassistant/bootstrap.py | 1 + .../components/automation/__init__.py | 1 + homeassistant/components/power/__init__.py | 17 + homeassistant/components/power/icons.json | 10 + homeassistant/components/power/manifest.json | 8 + homeassistant/components/power/strings.json | 76 +++++ homeassistant/components/power/trigger.py | 35 ++ homeassistant/components/power/triggers.yaml | 87 +++++ script/hassfest/manifest.py | 1 + script/hassfest/quality_scale.py | 1 + tests/components/power/__init__.py | 1 + tests/components/power/test_trigger.py | 312 ++++++++++++++++++ tests/snapshots/test_bootstrap.ambr | 2 + 14 files changed, 554 insertions(+) create mode 100644 homeassistant/components/power/__init__.py create mode 100644 homeassistant/components/power/icons.json create mode 100644 homeassistant/components/power/manifest.json create mode 100644 homeassistant/components/power/strings.json create mode 100644 homeassistant/components/power/trigger.py create mode 100644 homeassistant/components/power/triggers.yaml create mode 100644 tests/components/power/__init__.py create mode 100644 tests/components/power/test_trigger.py diff --git a/CODEOWNERS b/CODEOWNERS index 97305b97c1d..7dc825fde3b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1313,6 +1313,8 @@ build.json @home-assistant/supervisor /tests/components/poolsense/ @haemishkyd /homeassistant/components/portainer/ @erwindouna /tests/components/portainer/ @erwindouna +/homeassistant/components/power/ @home-assistant/core +/tests/components/power/ @home-assistant/core /homeassistant/components/powerfox/ @klaasnicolaas /tests/components/powerfox/ @klaasnicolaas /homeassistant/components/powerfox_local/ @klaasnicolaas diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 068b180a58d..46c39b6d86f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -250,6 +250,7 @@ DEFAULT_INTEGRATIONS = { "illuminance", "motion", "occupancy", + "power", "temperature", "window", } diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 1c5386f3cfa..264da0d3711 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -167,6 +167,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "motion", "occupancy", "person", + "power", "remote", "scene", "schedule", diff --git a/homeassistant/components/power/__init__.py b/homeassistant/components/power/__init__.py new file mode 100644 index 00000000000..87636a72167 --- /dev/null +++ b/homeassistant/components/power/__init__.py @@ -0,0 +1,17 @@ +"""Integration for power triggers.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "power" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + +__all__ = [] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/power/icons.json b/homeassistant/components/power/icons.json new file mode 100644 index 00000000000..4ec83ebf56a --- /dev/null +++ b/homeassistant/components/power/icons.json @@ -0,0 +1,10 @@ +{ + "triggers": { + "changed": { + "trigger": "mdi:flash" + }, + "crossed_threshold": { + "trigger": "mdi:flash" + } + } +} diff --git a/homeassistant/components/power/manifest.json b/homeassistant/components/power/manifest.json new file mode 100644 index 00000000000..94d9f5a987b --- /dev/null +++ b/homeassistant/components/power/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "power", + "name": "Power", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/power", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/power/strings.json b/homeassistant/components/power/strings.json new file mode 100644 index 00000000000..ace9284535b --- /dev/null +++ b/homeassistant/components/power/strings.json @@ -0,0 +1,76 @@ +{ + "common": { + "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "selector": { + "number_or_entity": { + "choices": { + "entity": "Entity", + "number": "Number" + } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + }, + "trigger_threshold_type": { + "options": { + "above": "Above", + "below": "Below", + "between": "Between", + "outside": "Outside" + } + } + }, + "title": "Power", + "triggers": { + "changed": { + "description": "Triggers after one or more power values change.", + "fields": { + "above": { + "description": "Only trigger when power is above this value.", + "name": "Above" + }, + "below": { + "description": "Only trigger when power is below this value.", + "name": "Below" + }, + "unit": { + "description": "All values will be converted to this unit when evaluating the trigger.", + "name": "Unit of measurement" + } + }, + "name": "Power changed" + }, + "crossed_threshold": { + "description": "Triggers after one or more power values cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::power::common::trigger_behavior_description%]", + "name": "[%key:component::power::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "The lower limit of the threshold.", + "name": "Lower limit" + }, + "threshold_type": { + "description": "The type of threshold to use.", + "name": "Threshold type" + }, + "unit": { + "description": "[%key:component::power::triggers::changed::fields::unit::description%]", + "name": "[%key:component::power::triggers::changed::fields::unit::name%]" + }, + "upper_limit": { + "description": "The upper limit of the threshold.", + "name": "Upper limit" + } + }, + "name": "Power crossed threshold" + } + } +} diff --git a/homeassistant/components/power/trigger.py b/homeassistant/components/power/trigger.py new file mode 100644 index 00000000000..6a2d3d8b1d8 --- /dev/null +++ b/homeassistant/components/power/trigger.py @@ -0,0 +1,35 @@ +"""Provides triggers for power.""" + +from __future__ import annotations + +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.const import UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.trigger import ( + Trigger, + make_entity_numerical_state_changed_with_unit_trigger, + make_entity_numerical_state_crossed_threshold_with_unit_trigger, +) +from homeassistant.util.unit_conversion import PowerConverter + +POWER_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { + NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.POWER), + SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.POWER), +} + + +TRIGGERS: dict[str, type[Trigger]] = { + "changed": make_entity_numerical_state_changed_with_unit_trigger( + POWER_DOMAIN_SPECS, UnitOfPower.WATT, PowerConverter + ), + "crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( + POWER_DOMAIN_SPECS, UnitOfPower.WATT, PowerConverter + ), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for power.""" + return TRIGGERS diff --git a/homeassistant/components/power/triggers.yaml b/homeassistant/components/power/triggers.yaml new file mode 100644 index 00000000000..d396de1b758 --- /dev/null +++ b/homeassistant/components/power/triggers.yaml @@ -0,0 +1,87 @@ +.trigger_common_fields: + behavior: &trigger_behavior + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +.number_or_entity: &number_or_entity + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: + - "mW" + - "W" + - "kW" + - "MW" + - "GW" + - "TW" + - "BTU/h" + - domain: sensor + device_class: power + - domain: number + device_class: power + translation_key: number_or_entity + +.trigger_threshold_type: &trigger_threshold_type + required: true + default: above + selector: + select: + options: + - above + - below + - between + - outside + translation_key: trigger_threshold_type + +.trigger_unit: &trigger_unit + required: false + selector: + select: + options: + - "mW" + - "W" + - "kW" + - "MW" + - "GW" + - "TW" + - "BTU/h" + +.trigger_target: &trigger_target + entity: + - domain: number + device_class: power + - domain: sensor + device_class: power + +changed: + target: *trigger_target + fields: + above: *number_or_entity + below: *number_or_entity + unit: *trigger_unit + +crossed_threshold: + target: *trigger_target + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity + upper_limit: *number_or_entity + unit: *trigger_unit diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index ae118a1f2bd..be91dc91ec5 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -110,6 +110,7 @@ NO_IOT_CLASS = [ "onboarding", "panel_custom", "plant", + "power", "profiler", "proxy", "python_script", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 0e5603c6013..78988d7a820 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2140,6 +2140,7 @@ NO_QUALITY_SCALE = [ "occupancy", "onboarding", "panel_custom", + "power", "proxy", "python_script", "raspberry_pi", diff --git a/tests/components/power/__init__.py b/tests/components/power/__init__.py new file mode 100644 index 00000000000..e2978fc15fd --- /dev/null +++ b/tests/components/power/__init__.py @@ -0,0 +1 @@ +"""Tests for the power integration.""" diff --git a/tests/components/power/test_trigger.py b/tests/components/power/test_trigger.py new file mode 100644 index 00000000000..1367fef6de9 --- /dev/null +++ b/tests/components/power/test_trigger.py @@ -0,0 +1,312 @@ +"""Test power trigger.""" + +from typing import Any + +import pytest + +from homeassistant.components.number import NumberDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components.common import ( + TriggerStateDescription, + assert_trigger_behavior_any, + assert_trigger_behavior_first, + assert_trigger_behavior_last, + assert_trigger_gated_by_labs_flag, + parametrize_numerical_state_value_changed_trigger_states, + parametrize_numerical_state_value_crossed_threshold_trigger_states, + parametrize_target_entities, + target_entities, +) + +_POWER_TRIGGER_OPTIONS = {"unit": UnitOfPower.WATT} +_POWER_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT} + + +@pytest.fixture +async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple number entities associated with different targets.""" + return await target_entities(hass, "number") + + +@pytest.fixture +async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple sensor entities associated with different targets.""" + return await target_entities(hass, "sensor") + + +@pytest.mark.parametrize( + "trigger_key", + [ + "power.changed", + "power.crossed_threshold", + ], +) +async def test_power_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the power triggers are gated by the labs flag.""" + await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_changed_trigger_states( + "power.changed", + device_class=SensorDeviceClass.POWER, + trigger_options=_POWER_TRIGGER_OPTIONS, + unit_attributes=_POWER_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "power.crossed_threshold", + device_class=SensorDeviceClass.POWER, + trigger_options=_POWER_TRIGGER_OPTIONS, + unit_attributes=_POWER_UNIT_ATTRIBUTES, + ), + ], +) +async def test_power_trigger_sensor_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test power trigger fires for sensor entities with device_class power.""" + await assert_trigger_behavior_any( + hass, + service_calls=service_calls, + target_entities=target_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "power.crossed_threshold", + device_class=SensorDeviceClass.POWER, + trigger_options=_POWER_TRIGGER_OPTIONS, + unit_attributes=_POWER_UNIT_ATTRIBUTES, + ), + ], +) +async def test_power_trigger_sensor_crossed_threshold_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test power crossed_threshold trigger fires on the first sensor state change.""" + await assert_trigger_behavior_first( + hass, + service_calls=service_calls, + target_entities=target_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "power.crossed_threshold", + device_class=SensorDeviceClass.POWER, + trigger_options=_POWER_TRIGGER_OPTIONS, + unit_attributes=_POWER_UNIT_ATTRIBUTES, + ), + ], +) +async def test_power_trigger_sensor_crossed_threshold_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test power crossed_threshold trigger fires when the last sensor changes state.""" + await assert_trigger_behavior_last( + hass, + service_calls=service_calls, + target_entities=target_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +# --- Number entity tests --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("number"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_changed_trigger_states( + "power.changed", + device_class=NumberDeviceClass.POWER, + trigger_options=_POWER_TRIGGER_OPTIONS, + unit_attributes=_POWER_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "power.crossed_threshold", + device_class=NumberDeviceClass.POWER, + trigger_options=_POWER_TRIGGER_OPTIONS, + unit_attributes=_POWER_UNIT_ATTRIBUTES, + ), + ], +) +async def test_power_trigger_number_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_numbers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test power trigger fires for number entities with device_class power.""" + await assert_trigger_behavior_any( + hass, + service_calls=service_calls, + target_entities=target_numbers, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("number"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "power.crossed_threshold", + device_class=NumberDeviceClass.POWER, + trigger_options=_POWER_TRIGGER_OPTIONS, + unit_attributes=_POWER_UNIT_ATTRIBUTES, + ), + ], +) +async def test_power_trigger_number_crossed_threshold_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_numbers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test power crossed_threshold trigger fires on the first number state change.""" + await assert_trigger_behavior_first( + hass, + service_calls=service_calls, + target_entities=target_numbers, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("number"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "power.crossed_threshold", + device_class=NumberDeviceClass.POWER, + trigger_options=_POWER_TRIGGER_OPTIONS, + unit_attributes=_POWER_UNIT_ATTRIBUTES, + ), + ], +) +async def test_power_trigger_number_crossed_threshold_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_numbers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test power crossed_threshold trigger fires when the last number changes state.""" + await assert_trigger_behavior_last( + hass, + service_calls=service_calls, + target_entities=target_numbers, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index a96c3775c0c..7a51e3d653b 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -74,6 +74,7 @@ 'occupancy', 'onboarding', 'person', + 'power', 'remote', 'repairs', 'scene', @@ -181,6 +182,7 @@ 'occupancy', 'onboarding', 'person', + 'power', 'remote', 'repairs', 'scene',