From 6f7a5d9320ad425f26477af21f398980aa99c931 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 18:48:08 +0000 Subject: [PATCH 001/138] Bump version to 2026.4.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6c0a918eb1e..93a789ef712 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index 0f6ae3ad4ff..fa18e5eee56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0.dev0" +version = "2026.4.0b0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From ade73ec1594cd350d6ca76e66deb82b746efa884 Mon Sep 17 00:00:00 2001 From: johanzander Date: Wed, 25 Mar 2026 20:00:47 +0100 Subject: [PATCH 002/138] growatt_server: use human-readable labels in exception messages (#166024) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Norbert Rittel --- .../components/growatt_server/services.py | 30 +++++++---- .../components/growatt_server/strings.json | 53 +++++++++++-------- .../growatt_server/test_services.py | 10 ++-- 3 files changed, 54 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/growatt_server/services.py b/homeassistant/components/growatt_server/services.py index bebab342a04..49728598179 100644 --- a/homeassistant/components/growatt_server/services.py +++ b/homeassistant/components/growatt_server/services.py @@ -87,22 +87,26 @@ def _get_coordinator( return coordinators[serial_number] -def _parse_time_str(time_str: str, field_name: str) -> time: +def _parse_time_str( + time_str: str, + translation_key: str, + translation_placeholders: dict[str, str] | None = None, +) -> time: """Parse a time string (HH:MM or HH:MM:SS) to a datetime.time object.""" parts = time_str.split(":") if len(parts) not in (2, 3): raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="invalid_time_format", - translation_placeholders={"field_name": field_name}, + translation_key=translation_key, + translation_placeholders=translation_placeholders or {}, ) try: return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time() except (ValueError, IndexError) as err: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="invalid_time_format", - translation_placeholders={"field_name": field_name}, + translation_key=translation_key, + translation_placeholders=translation_placeholders or {}, ) from err @@ -142,8 +146,8 @@ def async_setup_services(hass: HomeAssistant) -> None: ) batt_mode: int = valid_modes[batt_mode_str] - start_time = _parse_time_str(start_time_str, "start_time") - end_time = _parse_time_str(end_time_str, "end_time") + start_time = _parse_time_str(start_time_str, "invalid_time_format_start_time") + end_time = _parse_time_str(end_time_str, "invalid_time_format_end_time") coordinator: GrowattCoordinator = _get_coordinator(hass, device_id, "min") await coordinator.update_time_segment( @@ -192,11 +196,13 @@ def async_setup_services(hass: HomeAssistant) -> None: cached = current["periods"][i - 1] start = _parse_time_str( call.data.get(f"period_{i}_start", cached["start_time"]), - f"period_{i}_start", + "invalid_time_format_period_start", + {"period": str(i)}, ) end = _parse_time_str( call.data.get(f"period_{i}_end", cached["end_time"]), - f"period_{i}_end", + "invalid_time_format_period_end", + {"period": str(i)}, ) enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"]) periods.append({"start_time": start, "end_time": end, "enabled": enabled}) @@ -238,11 +244,13 @@ def async_setup_services(hass: HomeAssistant) -> None: cached = current["periods"][i - 1] start = _parse_time_str( call.data.get(f"period_{i}_start", cached["start_time"]), - f"period_{i}_start", + "invalid_time_format_period_start", + {"period": str(i)}, ) end = _parse_time_str( call.data.get(f"period_{i}_end", cached["end_time"]), - f"period_{i}_end", + "invalid_time_format_period_end", + {"period": str(i)}, ) enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"]) periods.append({"start_time": start, "end_time": end, "enabled": enabled}) diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index 12322055da4..ee65115f493 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -579,7 +579,7 @@ "message": "Growatt API error: {error}" }, "device_not_configured": { - "message": "{device_type} device {serial_number} is not configured for services." + "message": "{device_type} device {serial_number} is not configured for actions." }, "device_not_found": { "message": "Device {device_id} not found in the device registry." @@ -591,22 +591,31 @@ "message": "{batt_mode} is not a valid battery mode. Allowed values: {allowed_modes}." }, "invalid_charge_power": { - "message": "charge_power must be between 0 and 100, got {value}." + "message": "'Charge power' must be between 0 and 100, got {value}." }, "invalid_charge_stop_soc": { - "message": "charge_stop_soc must be between 0 and 100, got {value}." + "message": "'Charge stop SOC' must be between 0 and 100, got {value}." }, "invalid_discharge_power": { - "message": "discharge_power must be between 0 and 100, got {value}." + "message": "'Discharge power' must be between 0 and 100, got {value}." }, "invalid_discharge_stop_soc": { - "message": "discharge_stop_soc must be between 0 and 100, got {value}." + "message": "'Discharge stop SOC' must be between 0 and 100, got {value}." }, "invalid_segment_id": { - "message": "segment_id must be between 1 and 9, got {segment_id}." + "message": "'Segment ID' must be between 1 and 9, got {segment_id}." }, - "invalid_time_format": { - "message": "{field_name} must be in HH:MM or HH:MM:SS format." + "invalid_time_format_end_time": { + "message": "'End time' must be in HH:MM or HH:MM:SS format." + }, + "invalid_time_format_period_end": { + "message": "'Period {period} end' must be in HH:MM or HH:MM:SS format." + }, + "invalid_time_format_period_start": { + "message": "'Period {period} start' must be in HH:MM or HH:MM:SS format." + }, + "invalid_time_format_start_time": { + "message": "'Start time' must be in HH:MM or HH:MM:SS format." }, "no_devices_configured": { "message": "No {device_type} devices with token authentication are configured. Actions require {device_type} devices with V1 API access." @@ -636,27 +645,27 @@ }, "services": { "read_ac_charge_times": { - "description": "Read AC charge time periods from an SPH device.", + "description": "Reads AC charge time periods from an SPH device.", "fields": { "device_id": { - "description": "The Growatt SPH device to read from.", - "name": "Device" + "description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]" } }, "name": "Read AC charge times" }, "read_ac_discharge_times": { - "description": "Read AC discharge time periods from an SPH device.", + "description": "Reads AC discharge time periods from an SPH device.", "fields": { "device_id": { - "description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]", - "name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]" + "description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]" } }, "name": "Read AC discharge times" }, "read_time_segments": { - "description": "Read all time segments from a supported inverter.", + "description": "Reads all time segments from a supported inverter.", "fields": { "device_id": { "description": "The Growatt device to perform the action on.", @@ -666,7 +675,7 @@ "name": "Read time segments" }, "update_time_segment": { - "description": "Update a time segment for supported inverters.", + "description": "Updates a time segment for supported inverters.", "fields": { "batt_mode": { "description": "Battery operation mode for this time segment.", @@ -696,7 +705,7 @@ "name": "Update time segment" }, "write_ac_charge_times": { - "description": "Write AC charge time periods to an SPH device.", + "description": "Writes AC charge time periods to an SPH device.", "fields": { "charge_power": { "description": "Charge power limit (%).", @@ -707,8 +716,8 @@ "name": "Charge stop SOC" }, "device_id": { - "description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]", - "name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]" + "description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]" }, "mains_enabled": { "description": "Enable AC (mains) charging.", @@ -754,11 +763,11 @@ "name": "Write AC charge times" }, "write_ac_discharge_times": { - "description": "Write AC discharge time periods to an SPH device.", + "description": "Writes AC discharge time periods to an SPH device.", "fields": { "device_id": { - "description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]", - "name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]" + "description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]" }, "discharge_power": { "description": "Discharge power limit (%).", diff --git a/tests/components/growatt_server/test_services.py b/tests/components/growatt_server/test_services.py index b844eaa9651..d2dd4aa68ec 100644 --- a/tests/components/growatt_server/test_services.py +++ b/tests/components/growatt_server/test_services.py @@ -376,8 +376,7 @@ async def test_update_time_segment_invalid_time_format( blocking=True, ) assert excinfo.value.translation_domain == DOMAIN - assert excinfo.value.translation_key == "invalid_time_format" - assert excinfo.value.translation_placeholders == {"field_name": "start_time"} + assert excinfo.value.translation_key == "invalid_time_format_start_time" @pytest.mark.usefixtures("mock_growatt_v1_api") @@ -653,8 +652,7 @@ async def test_update_time_segment_invalid_end_time_format( blocking=True, ) assert excinfo.value.translation_domain == DOMAIN - assert excinfo.value.translation_key == "invalid_time_format" - assert excinfo.value.translation_placeholders == {"field_name": "end_time"} + assert excinfo.value.translation_key == "invalid_time_format_end_time" async def test_service_with_unloaded_config_entry( @@ -1056,8 +1054,8 @@ async def test_write_ac_charge_times_invalid_period_time( blocking=True, ) assert excinfo.value.translation_domain == DOMAIN - assert excinfo.value.translation_key == "invalid_time_format" - assert excinfo.value.translation_placeholders == {"field_name": "period_1_start"} + assert excinfo.value.translation_key == "invalid_time_format_period_start" + assert excinfo.value.translation_placeholders == {"period": "1"} async def test_no_sph_devices_fails_gracefully( From 5a7abc0a9222ff24fed2de798ef4505c053fc37f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Mar 2026 21:54:34 +0100 Subject: [PATCH 003/138] Add trigger water_heater.operation_mode_changed (#166450) --- .../components/water_heater/icons.json | 3 + .../components/water_heater/strings.json | 14 +++++ .../components/water_heater/trigger.py | 40 ++++++++++++- .../components/water_heater/triggers.yaml | 16 ++++++ tests/components/water_heater/test_trigger.py | 57 +++++++++++++++++++ 5 files changed, 128 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/water_heater/icons.json b/homeassistant/components/water_heater/icons.json index 2f4f36ee051..50729c852eb 100644 --- a/homeassistant/components/water_heater/icons.json +++ b/homeassistant/components/water_heater/icons.json @@ -53,6 +53,9 @@ } }, "triggers": { + "operation_mode_changed": { + "trigger": "mdi:water-boiler" + }, "target_temperature_changed": { "trigger": "mdi:thermometer" }, diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index ab359019931..eaacea425f8 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -200,6 +200,20 @@ }, "title": "Water heater", "triggers": { + "operation_mode_changed": { + "description": "Triggers after the operation mode of one or more water heaters changes to a specific mode.", + "fields": { + "behavior": { + "description": "[%key:component::water_heater::common::trigger_behavior_description%]", + "name": "[%key:component::water_heater::common::trigger_behavior_name%]" + }, + "operation_mode": { + "description": "The operation modes to trigger on.", + "name": "Operation mode" + } + }, + "name": "Water heater operation mode changed" + }, "target_temperature_changed": { "description": "Triggers after the temperature setpoint of one or more water heaters changes.", "fields": { diff --git a/homeassistant/components/water_heater/trigger.py b/homeassistant/components/water_heater/trigger.py index a08b5a13bef..786f5b75016 100644 --- a/homeassistant/components/water_heater/trigger.py +++ b/homeassistant/components/water_heater/trigger.py @@ -1,13 +1,24 @@ """Provides triggers for water heaters.""" -from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature +import voluptuous as vol + +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_OPTIONS, + STATE_OFF, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, EntityNumericalStateChangedTriggerWithUnitBase, EntityNumericalStateCrossedThresholdTriggerWithUnitBase, EntityNumericalStateTriggerWithUnitBase, + EntityTargetStateTriggerBase, Trigger, + TriggerConfig, make_entity_origin_state_trigger, make_entity_target_state_trigger, ) @@ -15,6 +26,30 @@ from homeassistant.util.unit_conversion import TemperatureConverter from .const import DOMAIN +CONF_OPERATION_MODE = "operation_mode" + +_OPERATION_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_OPERATION_MODE): vol.All( + cv.ensure_list, vol.Length(min=1), [str] + ), + }, + } +) + + +class WaterHeaterOperationModeChangedTrigger(EntityTargetStateTriggerBase): + """Trigger for water heater operation mode changes.""" + + _domain_specs = {DOMAIN: DomainSpec()} + _schema = _OPERATION_MODE_CHANGED_TRIGGER_SCHEMA + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the operation mode changed trigger.""" + super().__init__(hass, config) + self._to_states = set(self._options[CONF_OPERATION_MODE]) + class _WaterHeaterTargetTemperatureTriggerMixin( EntityNumericalStateTriggerWithUnitBase @@ -46,6 +81,7 @@ class WaterHeaterTargetTemperatureCrossedThresholdTrigger( TRIGGERS: dict[str, type[Trigger]] = { + "operation_mode_changed": WaterHeaterOperationModeChangedTrigger, "target_temperature_changed": WaterHeaterTargetTemperatureChangedTrigger, "target_temperature_crossed_threshold": WaterHeaterTargetTemperatureCrossedThresholdTrigger, "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), diff --git a/homeassistant/components/water_heater/triggers.yaml b/homeassistant/components/water_heater/triggers.yaml index 5fcb73c939e..581b7dbb58c 100644 --- a/homeassistant/components/water_heater/triggers.yaml +++ b/homeassistant/components/water_heater/triggers.yaml @@ -26,6 +26,22 @@ - domain: number device_class: temperature +operation_mode_changed: + target: *trigger_water_heater_target + fields: + behavior: *trigger_behavior + operation_mode: + context: + filter_target: target + required: true + selector: + state: + attribute: operation_mode + hide_states: + - unavailable + - unknown + multiple: true + turned_off: *trigger_common turned_on: *trigger_common diff --git a/tests/components/water_heater/test_trigger.py b/tests/components/water_heater/test_trigger.py index e21c99e6943..39c2ffad8b9 100644 --- a/tests/components/water_heater/test_trigger.py +++ b/tests/components/water_heater/test_trigger.py @@ -38,6 +38,8 @@ ALL_ON_STATES = [ STATE_PERFORMANCE, ] +ALL_STATES = [STATE_OFF, *ALL_ON_STATES] + @pytest.fixture async def target_water_heaters(hass: HomeAssistant) -> list[str]: @@ -48,6 +50,7 @@ async def target_water_heaters(hass: HomeAssistant) -> list[str]: @pytest.mark.parametrize( "trigger_key", [ + "water_heater.operation_mode_changed", "water_heater.target_temperature_changed", "water_heater.target_temperature_crossed_threshold", "water_heater.turned_off", @@ -69,6 +72,24 @@ async def test_water_heater_triggers_gated_by_labs_flag( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ + *( + param + for mode in ALL_STATES + for param in parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [mode]}, + target_states=[mode], + other_states=[s for s in ALL_STATES if s != mode], + ) + ), + *parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [STATE_ECO, STATE_ELECTRIC]}, + target_states=[STATE_ECO, STATE_ELECTRIC], + other_states=[ + s for s in ALL_STATES if s not in (STATE_ECO, STATE_ELECTRIC) + ], + ), *parametrize_trigger_states( trigger="water_heater.turned_off", target_states=[STATE_OFF], @@ -161,6 +182,24 @@ async def test_water_heater_state_attribute_trigger_behavior_any( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ + *( + param + for mode in ALL_STATES + for param in parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [mode]}, + target_states=[mode], + other_states=[s for s in ALL_STATES if s != mode], + ) + ), + *parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [STATE_ECO, STATE_ELECTRIC]}, + target_states=[STATE_ECO, STATE_ELECTRIC], + other_states=[ + s for s in ALL_STATES if s not in (STATE_ECO, STATE_ELECTRIC) + ], + ), *parametrize_trigger_states( trigger="water_heater.turned_off", target_states=[STATE_OFF], @@ -247,6 +286,24 @@ async def test_water_heater_state_attribute_trigger_behavior_first( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ + *( + param + for mode in ALL_STATES + for param in parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [mode]}, + target_states=[mode], + other_states=[s for s in ALL_STATES if s != mode], + ) + ), + *parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [STATE_ECO, STATE_ELECTRIC]}, + target_states=[STATE_ECO, STATE_ELECTRIC], + other_states=[ + s for s in ALL_STATES if s not in (STATE_ECO, STATE_ELECTRIC) + ], + ), *parametrize_trigger_states( trigger="water_heater.turned_off", target_states=[STATE_OFF], From a3c9d35a13b88cd34553fd4f2c55f3ff40e9608a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Mar 2026 20:57:12 +0100 Subject: [PATCH 004/138] Use NumericThresholdSelector in numeric conditions (#166507) --- .../components/air_quality/conditions.yaml | 605 +++++++----------- .../components/air_quality/strings.json | 172 ++--- .../components/battery/conditions.yaml | 40 +- homeassistant/components/battery/strings.json | 20 +- .../components/climate/conditions.yaml | 94 ++- homeassistant/components/climate/strings.json | 32 +- .../components/humidifier/conditions.yaml | 43 +- .../components/humidifier/strings.json | 18 +- .../components/humidity/conditions.yaml | 43 +- .../components/humidity/strings.json | 18 +- .../components/illuminance/conditions.yaml | 39 +- .../components/illuminance/strings.json | 18 +- .../components/moisture/conditions.yaml | 42 +- .../components/moisture/strings.json | 18 +- .../components/power/conditions.yaml | 78 +-- homeassistant/components/power/strings.json | 22 +- .../components/temperature/conditions.yaml | 51 +- .../components/temperature/strings.json | 22 +- .../components/water_heater/conditions.yaml | 51 +- .../components/water_heater/strings.json | 22 +- homeassistant/helpers/automation.py | 61 +- homeassistant/helpers/condition.py | 168 +++-- homeassistant/helpers/trigger.py | 30 +- .../components/air_quality/test_condition.py | 67 +- tests/components/climate/test_condition.py | 43 +- tests/components/common.py | 118 +++- tests/components/power/test_condition.py | 52 +- .../components/temperature/test_condition.py | 94 ++- .../components/water_heater/test_condition.py | 43 +- tests/helpers/test_condition.py | 247 +++++-- 30 files changed, 1139 insertions(+), 1232 deletions(-) diff --git a/homeassistant/components/air_quality/conditions.yaml b/homeassistant/components/air_quality/conditions.yaml index d2589bb612a..97b7c1056da 100644 --- a/homeassistant/components/air_quality/conditions.yaml +++ b/homeassistant/components/air_quality/conditions.yaml @@ -10,366 +10,155 @@ - all - any -# --- Number or entity selectors --- +# --- Unit lists for multi-unit pollutants --- -.number_or_entity_co: &number_or_entity_co - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "ppm" - - "mg/m³" - - "μg/m³" - - domain: sensor - device_class: carbon_monoxide - - domain: number - device_class: carbon_monoxide - translation_key: number_or_entity +.co_units: &co_units + - "ppb" + - "ppm" + - "mg/m³" + - "μg/m³" -.number_or_entity_co2: &number_or_entity_co2 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "ppm" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "ppm" - - domain: sensor - device_class: carbon_dioxide - - domain: number - device_class: carbon_dioxide - translation_key: number_or_entity +.ozone_units: &ozone_units + - "ppb" + - "ppm" + - "μg/m³" -.number_or_entity_pm1: &number_or_entity_pm1 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "μg/m³" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "μg/m³" - - domain: sensor - device_class: pm1 - - domain: number - device_class: pm1 - translation_key: number_or_entity +.voc_units: &voc_units + - "μg/m³" + - "mg/m³" -.number_or_entity_pm25: &number_or_entity_pm25 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "μg/m³" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "μg/m³" - - domain: sensor - device_class: pm25 - - domain: number - device_class: pm25 - translation_key: number_or_entity +.voc_ratio_units: &voc_ratio_units + - "ppb" + - "ppm" -.number_or_entity_pm4: &number_or_entity_pm4 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "μg/m³" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "μg/m³" - - domain: sensor - device_class: pm4 - - domain: number - device_class: pm4 - translation_key: number_or_entity +.no_units: &no_units + - "ppb" + - "μg/m³" -.number_or_entity_pm10: &number_or_entity_pm10 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "μg/m³" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "μg/m³" - - domain: sensor - device_class: pm10 - - domain: number - device_class: pm10 - translation_key: number_or_entity +.no2_units: &no2_units + - "ppb" + - "ppm" + - "μg/m³" -.number_or_entity_ozone: &number_or_entity_ozone - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "ppm" - - "μg/m³" - - domain: sensor - device_class: ozone - - domain: number - device_class: ozone - translation_key: number_or_entity +.so2_units: &so2_units + - "ppb" + - "μg/m³" -.number_or_entity_voc: &number_or_entity_voc - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "μg/m³" - - "mg/m³" - - domain: sensor - device_class: volatile_organic_compounds - - domain: number - device_class: volatile_organic_compounds - translation_key: number_or_entity +# --- Entity filter anchors --- -.number_or_entity_voc_ratio: &number_or_entity_voc_ratio - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "ppm" - - domain: sensor - device_class: volatile_organic_compounds_parts - - domain: number - device_class: volatile_organic_compounds_parts - translation_key: number_or_entity +.co_threshold_entity: &co_threshold_entity + - domain: input_number + unit_of_measurement: *co_units + - domain: sensor + device_class: carbon_monoxide + - domain: number + device_class: carbon_monoxide -.number_or_entity_no: &number_or_entity_no - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "μg/m³" - - domain: sensor - device_class: nitrogen_monoxide - - domain: number - device_class: nitrogen_monoxide - translation_key: number_or_entity +.co2_threshold_entity: &co2_threshold_entity + - domain: input_number + unit_of_measurement: "ppm" + - domain: sensor + device_class: carbon_dioxide + - domain: number + device_class: carbon_dioxide -.number_or_entity_no2: &number_or_entity_no2 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "ppm" - - "μg/m³" - - domain: sensor - device_class: nitrogen_dioxide - - domain: number - device_class: nitrogen_dioxide - translation_key: number_or_entity +.pm1_threshold_entity: &pm1_threshold_entity + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm1 + - domain: number + device_class: pm1 -.number_or_entity_n2o: &number_or_entity_n2o - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "μg/m³" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "μg/m³" - - domain: sensor - device_class: nitrous_oxide - - domain: number - device_class: nitrous_oxide - translation_key: number_or_entity +.pm25_threshold_entity: &pm25_threshold_entity + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm25 + - domain: number + device_class: pm25 -.number_or_entity_so2: &number_or_entity_so2 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "μg/m³" - - domain: sensor - device_class: sulphur_dioxide - - domain: number - device_class: sulphur_dioxide - translation_key: number_or_entity +.pm4_threshold_entity: &pm4_threshold_entity + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm4 + - domain: number + device_class: pm4 -# --- Unit selectors --- +.pm10_threshold_entity: &pm10_threshold_entity + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm10 + - domain: number + device_class: pm10 -.unit_co: &unit_co - required: false - selector: - select: - options: - - "ppb" - - "ppm" - - "mg/m³" - - "μg/m³" +.ozone_threshold_entity: &ozone_threshold_entity + - domain: input_number + unit_of_measurement: *ozone_units + - domain: sensor + device_class: ozone + - domain: number + device_class: ozone -.unit_ozone: &unit_ozone - required: false - selector: - select: - options: - - "ppb" - - "ppm" - - "μg/m³" +.voc_threshold_entity: &voc_threshold_entity + - domain: input_number + unit_of_measurement: *voc_units + - domain: sensor + device_class: volatile_organic_compounds + - domain: number + device_class: volatile_organic_compounds -.unit_no2: &unit_no2 - required: false - selector: - select: - options: - - "ppb" - - "ppm" - - "μg/m³" +.voc_ratio_threshold_entity: &voc_ratio_threshold_entity + - domain: input_number + unit_of_measurement: *voc_ratio_units + - domain: sensor + device_class: volatile_organic_compounds_parts + - domain: number + device_class: volatile_organic_compounds_parts -.unit_no: &unit_no - required: false - selector: - select: - options: - - "ppb" - - "μg/m³" +.no_threshold_entity: &no_threshold_entity + - domain: input_number + unit_of_measurement: *no_units + - domain: sensor + device_class: nitrogen_monoxide + - domain: number + device_class: nitrogen_monoxide -.unit_so2: &unit_so2 - required: false - selector: - select: - options: - - "ppb" - - "μg/m³" +.no2_threshold_entity: &no2_threshold_entity + - domain: input_number + unit_of_measurement: *no2_units + - domain: sensor + device_class: nitrogen_dioxide + - domain: number + device_class: nitrogen_dioxide -.unit_voc: &unit_voc - required: false - selector: - select: - options: - - "μg/m³" - - "mg/m³" +.n2o_threshold_entity: &n2o_threshold_entity + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: nitrous_oxide + - domain: number + device_class: nitrous_oxide -.unit_voc_ratio: &unit_voc_ratio - required: false - selector: - select: - options: - - "ppb" - - "ppm" +.so2_threshold_entity: &so2_threshold_entity + - domain: input_number + unit_of_measurement: *so2_units + - domain: sensor + device_class: sulphur_dioxide + - domain: number + device_class: sulphur_dioxide + +# --- Number anchors for single-unit pollutants --- + +.co2_threshold_number: &co2_threshold_number + mode: box + unit_of_measurement: "ppm" + +.ugm3_threshold_number: &ugm3_threshold_number + mode: box + unit_of_measurement: "μg/m³" # --- Binary sensor targets --- @@ -491,57 +280,99 @@ is_co_value: target: *target_co_sensor fields: behavior: *condition_behavior - above: *number_or_entity_co - below: *number_or_entity_co - unit: *unit_co + threshold: + required: true + selector: + numeric_threshold: + entity: *co_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *co_units is_ozone_value: target: *target_ozone fields: behavior: *condition_behavior - above: *number_or_entity_ozone - below: *number_or_entity_ozone - unit: *unit_ozone + threshold: + required: true + selector: + numeric_threshold: + entity: *ozone_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *ozone_units is_voc_value: target: *target_voc fields: behavior: *condition_behavior - above: *number_or_entity_voc - below: *number_or_entity_voc - unit: *unit_voc + threshold: + required: true + selector: + numeric_threshold: + entity: *voc_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *voc_units is_voc_ratio_value: target: *target_voc_ratio fields: behavior: *condition_behavior - above: *number_or_entity_voc_ratio - below: *number_or_entity_voc_ratio - unit: *unit_voc_ratio + threshold: + required: true + selector: + numeric_threshold: + entity: *voc_ratio_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *voc_ratio_units is_no_value: target: *target_no fields: behavior: *condition_behavior - above: *number_or_entity_no - below: *number_or_entity_no - unit: *unit_no + threshold: + required: true + selector: + numeric_threshold: + entity: *no_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *no_units is_no2_value: target: *target_no2 fields: behavior: *condition_behavior - above: *number_or_entity_no2 - below: *number_or_entity_no2 - unit: *unit_no2 + threshold: + required: true + selector: + numeric_threshold: + entity: *no2_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *no2_units is_so2_value: target: *target_so2 fields: behavior: *condition_behavior - above: *number_or_entity_so2 - below: *number_or_entity_so2 - unit: *unit_so2 + threshold: + required: true + selector: + numeric_threshold: + entity: *so2_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *so2_units # --- Numerical sensor conditions without unit conversion --- @@ -549,40 +380,70 @@ is_co2_value: target: *target_co2 fields: behavior: *condition_behavior - above: *number_or_entity_co2 - below: *number_or_entity_co2 + threshold: + required: true + selector: + numeric_threshold: + entity: *co2_threshold_entity + mode: is + number: *co2_threshold_number is_pm1_value: target: *target_pm1 fields: behavior: *condition_behavior - above: *number_or_entity_pm1 - below: *number_or_entity_pm1 + threshold: + required: true + selector: + numeric_threshold: + entity: *pm1_threshold_entity + mode: is + number: *ugm3_threshold_number is_pm25_value: target: *target_pm25 fields: behavior: *condition_behavior - above: *number_or_entity_pm25 - below: *number_or_entity_pm25 + threshold: + required: true + selector: + numeric_threshold: + entity: *pm25_threshold_entity + mode: is + number: *ugm3_threshold_number is_pm4_value: target: *target_pm4 fields: behavior: *condition_behavior - above: *number_or_entity_pm4 - below: *number_or_entity_pm4 + threshold: + required: true + selector: + numeric_threshold: + entity: *pm4_threshold_entity + mode: is + number: *ugm3_threshold_number is_pm10_value: target: *target_pm10 fields: behavior: *condition_behavior - above: *number_or_entity_pm10 - below: *number_or_entity_pm10 + threshold: + required: true + selector: + numeric_threshold: + entity: *pm10_threshold_entity + mode: is + number: *ugm3_threshold_number is_n2o_value: target: *target_n2o fields: behavior: *condition_behavior - above: *number_or_entity_n2o - below: *number_or_entity_n2o + threshold: + required: true + selector: + numeric_threshold: + entity: *n2o_threshold_entity + mode: is + number: *ugm3_threshold_number diff --git a/homeassistant/components/air_quality/strings.json b/homeassistant/components/air_quality/strings.json index 4a4d79e5b45..f3369398b34 100644 --- a/homeassistant/components/air_quality/strings.json +++ b/homeassistant/components/air_quality/strings.json @@ -1,13 +1,9 @@ { "common": { - "condition_above_description": "Require the value to be above this value.", - "condition_above_name": "Above", "condition_behavior_description": "How the value should match on the targeted entities.", "condition_behavior_name": "Behavior", - "condition_below_description": "Require the value to be below this value.", - "condition_below_name": "Below", - "condition_unit_description": "All values will be converted to this unit when evaluating the condition.", - "condition_unit_name": "Unit of measurement", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -18,17 +14,13 @@ "is_co2_value": { "description": "Tests the carbon dioxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Carbon dioxide value" @@ -56,21 +48,13 @@ "is_co_value": { "description": "Tests the carbon monoxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Carbon monoxide value" @@ -98,17 +82,13 @@ "is_n2o_value": { "description": "Tests the nitrous oxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Nitrous oxide value" @@ -116,21 +96,13 @@ "is_no2_value": { "description": "Tests the nitrogen dioxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Nitrogen dioxide value" @@ -138,21 +110,13 @@ "is_no_value": { "description": "Tests the nitrogen monoxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Nitrogen monoxide value" @@ -160,21 +124,13 @@ "is_ozone_value": { "description": "Tests the ozone level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Ozone value" @@ -182,17 +138,13 @@ "is_pm10_value": { "description": "Tests the PM10 level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "PM10 value" @@ -200,17 +152,13 @@ "is_pm1_value": { "description": "Tests the PM1 level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "PM1 value" @@ -218,17 +166,13 @@ "is_pm25_value": { "description": "Tests the PM2.5 level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "PM2.5 value" @@ -236,17 +180,13 @@ "is_pm4_value": { "description": "Tests the PM4 level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "PM4 value" @@ -274,21 +214,13 @@ "is_so2_value": { "description": "Tests the sulphur dioxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Sulphur dioxide value" @@ -296,21 +228,13 @@ "is_voc_ratio_value": { "description": "Tests the volatile organic compounds ratio of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Volatile organic compounds ratio value" @@ -318,21 +242,13 @@ "is_voc_value": { "description": "Tests the volatile organic compounds level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Volatile organic compounds value" @@ -345,12 +261,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/battery/conditions.yaml b/homeassistant/components/battery/conditions.yaml index fa37c37e6a3..98584b00044 100644 --- a/homeassistant/components/battery/conditions.yaml +++ b/homeassistant/components/battery/conditions.yaml @@ -14,24 +14,19 @@ - all - any -.number_or_entity: &number_or_entity - required: false - selector: - choose: - choices: - number: - selector: - number: - unit_of_measurement: "%" - entity: - selector: - entity: - filter: - domain: - - input_number - - number - - sensor - translation_key: number_or_entity +.battery_threshold_entity: &battery_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: sensor + device_class: battery + - domain: number + device_class: battery + +.battery_threshold_number: &battery_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" is_low: *condition_common @@ -62,5 +57,10 @@ is_level: device_class: battery fields: behavior: *condition_behavior - above: *number_or_entity - below: *number_or_entity + threshold: + required: true + selector: + numeric_threshold: + entity: *battery_threshold_entity + mode: is + number: *battery_threshold_number diff --git a/homeassistant/components/battery/strings.json b/homeassistant/components/battery/strings.json index 1b66656ce29..e0eec43b74e 100644 --- a/homeassistant/components/battery/strings.json +++ b/homeassistant/components/battery/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_description": "How the state should match on the targeted batteries.", - "condition_behavior_name": "Behavior" + "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration" }, "conditions": { "is_charging": { @@ -17,17 +19,13 @@ "is_level": { "description": "Tests the battery level of one or more batteries.", "fields": { - "above": { - "description": "Require the battery percentage to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" }, - "below": { - "description": "Require the battery percentage to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::battery::common::condition_threshold_description%]", + "name": "[%key:component::battery::common::condition_threshold_name%]" } }, "name": "Battery level" @@ -69,12 +67,6 @@ "all": "All", "any": "Any" } - }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } } }, "title": "Battery" diff --git a/homeassistant/components/climate/conditions.yaml b/homeassistant/components/climate/conditions.yaml index db40d7e444c..771d5e96332 100644 --- a/homeassistant/components/climate/conditions.yaml +++ b/homeassistant/components/climate/conditions.yaml @@ -13,58 +13,31 @@ - all - any -.number_or_entity_humidity: &number_or_entity_humidity - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "%" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "%" - - domain: sensor - device_class: humidity - - domain: number - device_class: humidity - translation_key: number_or_entity +.humidity_threshold_entity: &humidity_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: sensor + device_class: humidity + - domain: number + device_class: humidity -.number_or_entity_temperature: &number_or_entity_temperature - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "°C" - - "°F" - - domain: sensor - device_class: temperature - - domain: number - device_class: temperature - translation_key: number_or_entity +.humidity_threshold_number: &humidity_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" -.condition_unit_temperature: &condition_unit_temperature - required: false - selector: - select: - options: - - "°C" - - "°F" +.temperature_units: &temperature_units + - "°C" + - "°F" + +.temperature_threshold_entity: &temperature_threshold_entity + - domain: input_number + unit_of_measurement: *temperature_units + - domain: sensor + device_class: temperature + - domain: number + device_class: temperature is_off: *condition_common is_on: *condition_common @@ -76,13 +49,24 @@ target_humidity: target: *condition_climate_target fields: behavior: *condition_behavior - above: *number_or_entity_humidity - below: *number_or_entity_humidity + threshold: + required: true + selector: + numeric_threshold: + entity: *humidity_threshold_entity + mode: is + number: *humidity_threshold_number target_temperature: target: *condition_climate_target fields: behavior: *condition_behavior - above: *number_or_entity_temperature - below: *number_or_entity_temperature - unit: *condition_unit_temperature + threshold: + required: true + selector: + numeric_threshold: + entity: *temperature_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *temperature_units diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 8f2e680c0eb..ec6c99e51ab 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted climate-control devices.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted climates to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -62,17 +64,13 @@ "target_humidity": { "description": "Tests the humidity setpoint of one or more climate-control devices.", "fields": { - "above": { - "description": "Require the target humidity to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, - "below": { - "description": "Require the target humidity to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::climate::common::condition_threshold_description%]", + "name": "[%key:component::climate::common::condition_threshold_name%]" } }, "name": "Climate-control device target humidity" @@ -80,21 +78,13 @@ "target_temperature": { "description": "Tests the temperature setpoint of one or more climate-control devices.", "fields": { - "above": { - "description": "Require the target temperature to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, - "below": { - "description": "Require the target temperature to be below this value.", - "name": "Below" - }, - "unit": { - "description": "All values will be converted to this unit when evaluating the condition.", - "name": "Unit of measurement" + "threshold": { + "description": "[%key:component::climate::common::condition_threshold_description%]", + "name": "[%key:component::climate::common::condition_threshold_name%]" } }, "name": "Climate-control device target temperature" @@ -284,12 +274,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/humidifier/conditions.yaml b/homeassistant/components/humidifier/conditions.yaml index 6ed179e3caa..bc10ab1db65 100644 --- a/homeassistant/components/humidifier/conditions.yaml +++ b/homeassistant/components/humidifier/conditions.yaml @@ -13,27 +13,19 @@ - all - any -.number_or_entity: &number_or_entity - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "%" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "%" - - domain: sensor - device_class: humidity - - domain: number - device_class: humidity - translation_key: number_or_entity +.humidity_threshold_entity: &humidity_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: sensor + device_class: humidity + - domain: number + device_class: humidity + +.humidity_threshold_number: &humidity_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" is_off: *condition_common is_on: *condition_common @@ -44,5 +36,10 @@ is_target_humidity: target: *condition_humidifier_target fields: behavior: *condition_behavior - above: *number_or_entity - below: *number_or_entity + threshold: + required: true + selector: + numeric_threshold: + entity: *humidity_threshold_entity + mode: is + number: *humidity_threshold_number diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index fc729062c4a..09b01ce14de 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted humidifiers.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted humidifiers to trigger on.", "trigger_behavior_name": "Behavior" }, @@ -49,17 +51,13 @@ "is_target_humidity": { "description": "Tests the target humidity of one or more humidifiers.", "fields": { - "above": { - "description": "Require the target humidity to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" }, - "below": { - "description": "Require the target humidity to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::humidifier::common::condition_threshold_description%]", + "name": "[%key:component::humidifier::common::condition_threshold_name%]" } }, "name": "Humidifier target humidity" @@ -159,12 +157,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/humidity/conditions.yaml b/homeassistant/components/humidity/conditions.yaml index 4fc6cd34963..733b2452891 100644 --- a/homeassistant/components/humidity/conditions.yaml +++ b/homeassistant/components/humidity/conditions.yaml @@ -1,24 +1,16 @@ -.number_or_entity: &number_or_entity - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "%" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "%" - - domain: number - device_class: humidity - - domain: sensor - device_class: humidity - translation_key: number_or_entity +.humidity_threshold_entity: &humidity_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: sensor + device_class: humidity + - domain: number + device_class: humidity + +.humidity_threshold_number: &humidity_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" is_value: target: @@ -39,5 +31,10 @@ is_value: options: - all - any - above: *number_or_entity - below: *number_or_entity + threshold: + required: true + selector: + numeric_threshold: + entity: *humidity_threshold_entity + mode: is + number: *humidity_threshold_number diff --git a/homeassistant/components/humidity/strings.json b/homeassistant/components/humidity/strings.json index 9327bf89e18..06836f05dce 100644 --- a/homeassistant/components/humidity/strings.json +++ b/homeassistant/components/humidity/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted entities.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -12,17 +14,13 @@ "is_value": { "description": "Tests if a relative humidity value is above a threshold, below a threshold, or in a range of values.", "fields": { - "above": { - "description": "Require the relative humidity to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::humidity::common::condition_behavior_description%]", "name": "[%key:component::humidity::common::condition_behavior_name%]" }, - "below": { - "description": "Require the relative humidity to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::humidity::common::condition_threshold_description%]", + "name": "[%key:component::humidity::common::condition_threshold_name%]" } }, "name": "Relative humidity" @@ -35,12 +33,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/illuminance/conditions.yaml b/homeassistant/components/illuminance/conditions.yaml index d2e07200c4b..37980efcae4 100644 --- a/homeassistant/components/illuminance/conditions.yaml +++ b/homeassistant/components/illuminance/conditions.yaml @@ -14,27 +14,6 @@ - all - any -.number_or_entity: &number_or_entity - required: false - selector: - choose: - choices: - number: - selector: - number: - unit_of_measurement: "lx" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "lx" - - domain: number - device_class: illuminance - - domain: sensor - device_class: illuminance - translation_key: number_or_entity - is_detected: *detected_condition_common is_not_detected: *detected_condition_common @@ -48,5 +27,19 @@ is_value: device_class: illuminance fields: behavior: *condition_behavior - above: *number_or_entity - below: *number_or_entity + threshold: + required: true + selector: + numeric_threshold: + entity: + - domain: input_number + unit_of_measurement: "lx" + - domain: sensor + device_class: illuminance + - domain: number + device_class: illuminance + mode: is + number: + min: 0 + mode: box + unit_of_measurement: "lx" diff --git a/homeassistant/components/illuminance/strings.json b/homeassistant/components/illuminance/strings.json index aa5090a5d35..5ed11170df0 100644 --- a/homeassistant/components/illuminance/strings.json +++ b/homeassistant/components/illuminance/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted entities.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -32,17 +34,13 @@ "is_value": { "description": "Tests the illuminance value.", "fields": { - "above": { - "description": "Require the illuminance to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::illuminance::common::condition_behavior_description%]", "name": "[%key:component::illuminance::common::condition_behavior_name%]" }, - "below": { - "description": "Require the illuminance to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::illuminance::common::condition_threshold_description%]", + "name": "[%key:component::illuminance::common::condition_threshold_name%]" } }, "name": "Illuminance" @@ -55,12 +53,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/moisture/conditions.yaml b/homeassistant/components/moisture/conditions.yaml index 03818912730..a1e1f9b4bfd 100644 --- a/homeassistant/components/moisture/conditions.yaml +++ b/homeassistant/components/moisture/conditions.yaml @@ -14,26 +14,19 @@ - all - any -.number_or_entity: &number_or_entity - required: false - selector: - choose: - choices: - number: - selector: - number: - unit_of_measurement: "%" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "%" - - domain: number - device_class: moisture - - domain: sensor - device_class: moisture - translation_key: number_or_entity +.moisture_threshold_entity: &moisture_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: sensor + device_class: moisture + - domain: number + device_class: moisture + +.moisture_threshold_number: &moisture_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" is_detected: *detected_condition_common @@ -48,5 +41,10 @@ is_value: device_class: moisture fields: behavior: *condition_behavior - above: *number_or_entity - below: *number_or_entity + threshold: + required: true + selector: + numeric_threshold: + entity: *moisture_threshold_entity + mode: is + number: *moisture_threshold_number diff --git a/homeassistant/components/moisture/strings.json b/homeassistant/components/moisture/strings.json index e4e33bbe061..c2f9705bcca 100644 --- a/homeassistant/components/moisture/strings.json +++ b/homeassistant/components/moisture/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted entities.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -32,17 +34,13 @@ "is_value": { "description": "Tests the moisture level of one or more entities.", "fields": { - "above": { - "description": "Require the moisture level to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::moisture::common::condition_behavior_description%]", "name": "[%key:component::moisture::common::condition_behavior_name%]" }, - "below": { - "description": "Require the moisture level to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::moisture::common::condition_threshold_description%]", + "name": "[%key:component::moisture::common::condition_threshold_name%]" } }, "name": "Moisture level" @@ -55,12 +53,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/power/conditions.yaml b/homeassistant/components/power/conditions.yaml index c9a3498c186..a34beb6d24a 100644 --- a/homeassistant/components/power/conditions.yaml +++ b/homeassistant/components/power/conditions.yaml @@ -1,43 +1,29 @@ -.number_or_entity_power: &number_or_entity_power - 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 - -.condition_unit_power: &condition_unit_power - required: false +.condition_behavior: &condition_behavior + required: true + default: any selector: select: + translation_key: condition_behavior options: - - "mW" - - "W" - - "kW" - - "MW" - - "GW" - - "TW" - - "BTU/h" + - all + - any + +.power_units: &power_units + - "mW" + - "W" + - "kW" + - "MW" + - "GW" + - "TW" + - "BTU/h" + +.power_threshold_entity: &power_threshold_entity + - domain: input_number + unit_of_measurement: *power_units + - domain: sensor + device_class: power + - domain: number + device_class: power is_value: target: @@ -47,15 +33,13 @@ is_value: - domain: sensor device_class: power fields: - behavior: + behavior: *condition_behavior + threshold: required: true - default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any - above: *number_or_entity_power - below: *number_or_entity_power - unit: *condition_unit_power + numeric_threshold: + entity: *power_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *power_units diff --git a/homeassistant/components/power/strings.json b/homeassistant/components/power/strings.json index 3f7ba415b7f..f4369b0e225 100644 --- a/homeassistant/components/power/strings.json +++ b/homeassistant/components/power/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the power value should match on the targeted entities.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -12,21 +14,13 @@ "is_value": { "description": "Tests the power value of one or more entities.", "fields": { - "above": { - "description": "Require the power to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::power::common::condition_behavior_description%]", "name": "[%key:component::power::common::condition_behavior_name%]" }, - "below": { - "description": "Require the power to be below this value.", - "name": "Below" - }, - "unit": { - "description": "All values will be converted to this unit when evaluating the condition.", - "name": "Unit of measurement" + "threshold": { + "description": "[%key:component::power::common::condition_threshold_description%]", + "name": "[%key:component::power::common::condition_threshold_name%]" } }, "name": "Power value" @@ -39,12 +33,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/temperature/conditions.yaml b/homeassistant/components/temperature/conditions.yaml index bb87a665924..a979b371e00 100644 --- a/homeassistant/components/temperature/conditions.yaml +++ b/homeassistant/components/temperature/conditions.yaml @@ -1,33 +1,14 @@ -.number_or_entity_temperature: &number_or_entity_temperature - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "°C" - - "°F" - - domain: sensor - device_class: temperature - - domain: number - device_class: temperature - translation_key: number_or_entity +.temperature_units: &temperature_units + - "°C" + - "°F" -.condition_unit_temperature: &condition_unit_temperature - required: false - selector: - select: - options: - - "°C" - - "°F" +.temperature_threshold_entity: &temperature_threshold_entity + - domain: input_number + unit_of_measurement: *temperature_units + - domain: sensor + device_class: temperature + - domain: number + device_class: temperature is_value: target: @@ -47,6 +28,12 @@ is_value: options: - all - any - above: *number_or_entity_temperature - below: *number_or_entity_temperature - unit: *condition_unit_temperature + threshold: + required: true + selector: + numeric_threshold: + entity: *temperature_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *temperature_units diff --git a/homeassistant/components/temperature/strings.json b/homeassistant/components/temperature/strings.json index e20fd7b0c7d..e1c74365759 100644 --- a/homeassistant/components/temperature/strings.json +++ b/homeassistant/components/temperature/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the temperature should match on the targeted entities.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -12,21 +14,13 @@ "is_value": { "description": "Tests the temperature of one or more entities.", "fields": { - "above": { - "description": "Require the temperature to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::temperature::common::condition_behavior_description%]", "name": "[%key:component::temperature::common::condition_behavior_name%]" }, - "below": { - "description": "Require the temperature to be below this value.", - "name": "Below" - }, - "unit": { - "description": "All values will be converted to this unit when evaluating the condition.", - "name": "Unit of measurement" + "threshold": { + "description": "[%key:component::temperature::common::condition_threshold_description%]", + "name": "[%key:component::temperature::common::condition_threshold_name%]" } }, "name": "Temperature value" @@ -39,12 +33,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/water_heater/conditions.yaml b/homeassistant/components/water_heater/conditions.yaml index 6ce7ec9747e..a200dfcf832 100644 --- a/homeassistant/components/water_heater/conditions.yaml +++ b/homeassistant/components/water_heater/conditions.yaml @@ -13,36 +13,17 @@ - all - any -.number_or_entity_temperature: &number_or_entity_temperature - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "°C" - - "°F" - - domain: sensor - device_class: temperature - - domain: number - device_class: temperature - translation_key: number_or_entity +.temperature_units: &temperature_units + - "°C" + - "°F" -.condition_unit_temperature: &condition_unit_temperature - required: false - selector: - select: - options: - - "°C" - - "°F" +.temperature_threshold_entity: &temperature_threshold_entity + - domain: input_number + unit_of_measurement: *temperature_units + - domain: sensor + device_class: temperature + - domain: number + device_class: temperature is_off: *condition_common is_on: *condition_common @@ -67,6 +48,12 @@ is_target_temperature: target: *condition_water_heater_target fields: behavior: *condition_behavior - above: *number_or_entity_temperature - below: *number_or_entity_temperature - unit: *condition_unit_temperature + threshold: + required: true + selector: + numeric_threshold: + entity: *temperature_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *temperature_units diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index eaacea425f8..df8ce5a1297 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted water heaters.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted water heaters to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -46,21 +48,13 @@ "is_target_temperature": { "description": "Tests the temperature setpoint of one or more water heaters.", "fields": { - "above": { - "description": "Require the target temperature to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" }, - "below": { - "description": "Require the target temperature to be below this value.", - "name": "Below" - }, - "unit": { - "description": "All values will be converted to this unit when evaluating the condition.", - "name": "Unit of measurement" + "threshold": { + "description": "[%key:component::water_heater::common::condition_threshold_description%]", + "name": "[%key:component::water_heater::common::condition_threshold_name%]" } }, "name": "Water heater target temperature" @@ -140,12 +134,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py index 318e920fb1c..83f827ad75e 100644 --- a/homeassistant/helpers/automation.py +++ b/homeassistant/helpers/automation.py @@ -3,16 +3,15 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from enum import Enum -from typing import Any, Final +from typing import Any, Final, Self import voluptuous as vol from homeassistant.const import CONF_OPTIONS from homeassistant.core import HomeAssistant, split_entity_id -from . import config_validation as cv from .entity import get_device_class_or_undefined -from .typing import ConfigType +from .typing import UNDEFINED, ConfigType, UndefinedType CONF_UNIT: Final = "unit" @@ -145,41 +144,29 @@ def move_options_fields_to_top_level( return new_config -_NUMBER_OR_ENTITY_CHOOSE_SCHEMA = vol.Schema( - { - vol.Required("active_choice"): vol.In(["number", "entity"]), - vol.Optional("entity"): cv.entity_id, - vol.Optional("number"): vol.Coerce(float), - } -) +@dataclass(frozen=True, kw_only=True) +class ThresholdConfig: + """Configuration for threshold conditions and triggers.""" + numerical: bool + entity: str | None + number: float | None + unit: str | None | UndefinedType -def _validate_number_or_entity(value: dict | float | str) -> float | str: - """Validate number or entity selector result.""" - if isinstance(value, dict): - _NUMBER_OR_ENTITY_CHOOSE_SCHEMA(value) - return value[value["active_choice"]] # type: ignore[no-any-return] - return value + @classmethod + def from_config(cls, config: dict[str, Any] | None) -> Self | None: + """Create ThresholdConfig from config dict.""" + if config is None: + return None + entity: str | None = None + number: float | None = None + unit: str | None | UndefinedType = UNDEFINED + numerical = "number" in config + if numerical: + number = config["number"] + unit = config.get("unit_of_measurement", UNDEFINED) + else: + entity = config["entity"] -number_or_entity = vol.All( - _validate_number_or_entity, vol.Any(vol.Coerce(float), cv.entity_id) -) - - -def validate_unit_set_if_range_numerical[_T: dict[str, Any]]( - lower_limit: str, upper_limit: str -) -> Callable[[_T], _T]: - """Validate that unit is set if upper or lower limit is numerical.""" - - def _validate_unit_set_if_range_numerical_impl(options: _T) -> _T: - if ( - any( - opt in options and not isinstance(options[opt], str) - for opt in (lower_limit, upper_limit) - ) - ) and CONF_UNIT not in options: - raise vol.Invalid("Unit must be specified when using numerical thresholds.") - return options - - return _validate_unit_set_if_range_numerical_impl + return cls(numerical=numerical, number=number, entity=entity, unit=unit) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 0456629a0b1..967ddefe1b8 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -78,17 +78,21 @@ from homeassistant.util.yaml import load_yaml_dict from . import config_validation as cv, entity_registry as er, selector from .automation import ( - CONF_UNIT, DomainSpec, + ThresholdConfig, filter_by_domain_specs, get_absolute_description_key, get_relative_description_key, move_options_fields_to_top_level, - number_or_entity, - validate_unit_set_if_range_numerical, ) from .integration_platform import async_process_integration_platforms -from .selector import TargetSelector +from .selector import ( + NumericThresholdMode, + NumericThresholdSelector, + NumericThresholdSelectorConfig, + NumericThresholdType, + TargetSelector, +) from .target import TargetSelection, async_extract_referenced_entity_ids from .template import Template, render_complex from .trace import ( @@ -458,22 +462,6 @@ def make_entity_state_condition( return CustomCondition -def _validate_above_below(config: dict[str, Any]) -> dict[str, Any]: - """Validate that above < below when both are set.""" - above = config.get(CONF_ABOVE) - below = config.get(CONF_BELOW) - if above is None or below is None: - return config - if isinstance(above, str) or isinstance(below, str): - return config - if above >= below: - raise vol.Invalid( - f"A value can never be above {above} and below {below} at the same" - " time. You probably want two different conditions." - ) - return config - - NUMERICAL_CONDITION_SCHEMA = vol.Schema( { vol.Required(CONF_TARGET): cv.TARGET_FIELDS, @@ -482,11 +470,10 @@ NUMERICAL_CONDITION_SCHEMA = vol.Schema( vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( [BEHAVIOR_ANY, BEHAVIOR_ALL] ), - vol.Optional(CONF_ABOVE): number_or_entity, - vol.Optional(CONF_BELOW): number_or_entity, + vol.Required("threshold"): NumericThresholdSelector( + NumericThresholdSelectorConfig(mode=NumericThresholdMode.IS) + ), }, - cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), - _validate_above_below, ), } ) @@ -503,8 +490,15 @@ class EntityNumericalConditionBase(EntityConditionBase): super().__init__(hass, config) if TYPE_CHECKING: assert config.options is not None - self._above: float | str | None = config.options.get(CONF_ABOVE) - self._below: float | str | None = config.options.get(CONF_BELOW) + threshold_options: dict[str, Any] = config.options["threshold"] + self.threshold = ThresholdConfig.from_config(threshold_options.get("value")) + self.lower_threshold = ThresholdConfig.from_config( + threshold_options.get("value_min") + ) + self.upper_threshold = ThresholdConfig.from_config( + threshold_options.get("value_max") + ) + self._threshold_type = threshold_options["type"] def _is_valid_unit(self, unit: str | None) -> bool: """Check if the given unit is valid for this condition.""" @@ -512,20 +506,26 @@ class EntityNumericalConditionBase(EntityConditionBase): return True return unit == self._valid_unit - def _get_numerical_value(self, entity_or_float: float | str) -> float | None: - """Get numerical value from float or entity state.""" - if isinstance(entity_or_float, str): - if not (ref_state := self._hass.states.get(entity_or_float)): - return None - if not self._is_valid_unit( - ref_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - ): - return None - try: - return float(ref_state.state) - except TypeError, ValueError: - return None - return entity_or_float + def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None: + """Get threshold value from float or entity state.""" + if threshold is None: + return None + if threshold.numerical: + return threshold.number + + if not (entity_state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type] + # Entity not found + return None + if not self._is_valid_unit( + entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + ): + # Entity unit does not match the expected unit + return None + try: + return float(entity_state.state) + except TypeError, ValueError: + # Entity state is not a valid number + return None def _get_tracked_value(self, entity_state: State) -> Any: """Get the tracked value from a state, with unit validation for state-based values.""" @@ -545,17 +545,27 @@ class EntityNumericalConditionBase(EntityConditionBase): except TypeError, ValueError: return False - if self._above is not None: - if (above := self._get_numerical_value(self._above)) is None: + if self._threshold_type == NumericThresholdType.ABOVE: + if (limit := self._get_threshold_value(self.threshold)) is None: + # Entity not found or invalid number, don't trigger return False - if value <= above: + return value > limit + if self._threshold_type == NumericThresholdType.BELOW: + if (limit := self._get_threshold_value(self.threshold)) is None: + # Entity not found or invalid number, don't trigger return False - if self._below is not None: - if (below := self._get_numerical_value(self._below)) is None: - return False - if value >= below: - return False - return True + return value < limit + + # Mode is BETWEEN or OUTSIDE + lower_limit = self._get_threshold_value(self.lower_threshold) + upper_limit = self._get_threshold_value(self.upper_threshold) + if lower_limit is None or upper_limit is None: + # Entity not found or invalid number, don't trigger + return False + between = lower_limit < value < upper_limit + if self._threshold_type == NumericThresholdType.BETWEEN: + return between + return not between def make_entity_numerical_condition( @@ -586,13 +596,13 @@ def _make_numerical_condition_with_unit_schema( vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( [BEHAVIOR_ANY, BEHAVIOR_ALL] ), - vol.Optional(CONF_ABOVE): number_or_entity, - vol.Optional(CONF_BELOW): number_or_entity, - vol.Optional(CONF_UNIT): vol.In(unit_converter.VALID_UNITS), + vol.Required("threshold"): NumericThresholdSelector( + NumericThresholdSelectorConfig( + mode=NumericThresholdMode.IS, + unit_of_measurement=list(unit_converter.VALID_UNITS), + ) + ), }, - cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), - _validate_above_below, - validate_unit_set_if_range_numerical(CONF_ABOVE, CONF_BELOW), ), } ) @@ -602,16 +612,8 @@ class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase): """Condition for numerical state comparisons with unit conversion.""" _base_unit: str | None # Base unit for the tracked value - _manual_limit_unit: str | None # Unit of above/below limits when numbers _unit_converter: type[BaseUnitConverter] - def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: - """Initialize the numerical condition with unit conversion.""" - super().__init__(hass, config) - if TYPE_CHECKING: - assert config.options is not None - self._manual_limit_unit = config.options.get(CONF_UNIT) - def __init_subclass__(cls, **kwargs: Any) -> None: """Create a schema.""" super().__init_subclass__(**kwargs) @@ -621,25 +623,34 @@ class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase): """Get the unit of an entity from its state.""" return entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - def _get_numerical_value(self, entity_or_float: float | str) -> float | None: - """Get numerical value from float or entity state.""" - if isinstance(entity_or_float, (int, float)): + def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None: + """Get threshold value from float or entity state.""" + if threshold is None: + return None + if threshold.numerical: return self._unit_converter.convert( - entity_or_float, self._manual_limit_unit, self._base_unit + threshold.number, # type: ignore[arg-type] + threshold.unit, # type: ignore[arg-type] + self._base_unit, ) - if not (_state := self._hass.states.get(entity_or_float)): + if not (entity_state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type] + # Entity not found return None try: - value = float(_state.state) + value = float(entity_state.state) except TypeError, ValueError: + # Entity state is not a valid number return None try: return self._unit_converter.convert( - value, _state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), self._base_unit + value, + entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), + self._base_unit, ) except HomeAssistantError: + # Unit conversion failed (i.e. incompatible units), treat as invalid number return None def _get_tracked_value(self, entity_state: State) -> Any: @@ -663,23 +674,6 @@ class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase): except HomeAssistantError: return None - def is_valid_state(self, entity_state: State) -> bool: - """Check if the state is within the specified range.""" - if (value := self._get_tracked_value(entity_state)) is None: - return False - - if self._above is not None: - if (above := self._get_numerical_value(self._above)) is None: - return False - if value <= above: - return False - if self._below is not None: - if (below := self._get_numerical_value(self._below)) is None: - return False - if value >= below: - return False - return True - def make_entity_numerical_condition_with_unit( domain_specs: Mapping[str, DomainSpec], diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 25a0c35b8cb..fefbc416cb1 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -16,7 +16,6 @@ from typing import ( Final, Literal, Protocol, - Self, TypedDict, cast, override, @@ -70,6 +69,7 @@ from . import config_validation as cv, selector from .automation import ( DomainSpec, NumericalDomainSpec, + ThresholdConfig, filter_by_domain_specs, get_absolute_description_key, get_relative_description_key, @@ -535,34 +535,6 @@ NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( ) -@dataclass(frozen=True, kw_only=True) -class ThresholdConfig: - """Configuration for threshold triggers.""" - - numerical: bool - entity: str | None - number: float | None - unit: str | None | UndefinedType - - @classmethod - def from_config(cls, config: dict[str, Any] | None) -> Self | None: - """Create ThresholdConfig from config dict.""" - if config is None: - return None - - entity: str | None = None - number: float | None = None - unit: str | None | UndefinedType = UNDEFINED - numerical = "number" in config - if numerical: - number = config["number"] - unit = config.get("unit_of_measurement", UNDEFINED) - else: - entity = config["entity"] - - return cls(numerical=numerical, number=number, entity=entity, unit=unit) - - class EntityNumericalStateTriggerBase(EntityTriggerBase[NumericalDomainSpec]): """Base class for numerical state and state attribute triggers.""" diff --git a/tests/components/air_quality/test_condition.py b/tests/components/air_quality/test_condition.py index 1ba70a346c5..61eda516827 100644 --- a/tests/components/air_quality/test_condition.py +++ b/tests/components/air_quality/test_condition.py @@ -11,8 +11,6 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - CONF_ABOVE, - CONF_BELOW, STATE_OFF, STATE_ON, ) @@ -32,11 +30,9 @@ from tests.components.common import ( target_entities, ) -_UGM3_CONDITION_OPTIONS = {"unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER} _UGM3_UNIT_ATTRIBUTES = { ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER } -_PPB_CONDITION_OPTIONS = {"unit": CONCENTRATION_PARTS_PER_BILLION} _PPB_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION} _PPM_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION} @@ -241,43 +237,43 @@ async def test_air_quality_binary_condition_behavior_all( *parametrize_numerical_condition_above_below_any( "air_quality.is_co_value", device_class="carbon_monoxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_ozone_value", device_class="ozone", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_voc_value", device_class="volatile_organic_compounds", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_voc_ratio_value", device_class="volatile_organic_compounds_parts", - condition_options=_PPB_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_PARTS_PER_BILLION, unit_attributes=_PPB_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_no_value", device_class="nitrogen_monoxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_no2_value", device_class="nitrogen_dioxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_so2_value", device_class="sulphur_dioxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), ], @@ -316,43 +312,43 @@ async def test_air_quality_numerical_with_unit_condition_behavior_any( *parametrize_numerical_condition_above_below_all( "air_quality.is_co_value", device_class="carbon_monoxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_ozone_value", device_class="ozone", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_voc_value", device_class="volatile_organic_compounds", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_voc_ratio_value", device_class="volatile_organic_compounds_parts", - condition_options=_PPB_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_PARTS_PER_BILLION, unit_attributes=_PPB_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_no_value", device_class="nitrogen_monoxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_no2_value", device_class="nitrogen_dioxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_so2_value", device_class="sulphur_dioxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), ], @@ -539,19 +535,38 @@ async def test_air_quality_condition_unit_conversion_co( ], numerical_condition_options=[ { - CONF_ABOVE: 0.2, - CONF_BELOW: 0.8, - "unit": CONCENTRATION_PARTS_PER_MILLION, + "threshold": { + "type": "between", + "value_min": { + "number": 0.2, + "unit_of_measurement": CONCENTRATION_PARTS_PER_MILLION, + }, + "value_max": { + "number": 0.8, + "unit_of_measurement": CONCENTRATION_PARTS_PER_MILLION, + }, + } }, { - CONF_ABOVE: 200, - CONF_BELOW: 800, - "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "threshold": { + "type": "between", + "value_min": { + "number": 200, + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + "value_max": { + "number": 800, + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + } }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 40f8407dfe5..2d1305b4850 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -13,8 +13,6 @@ from homeassistant.components.climate.const import ( from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, UnitOfTemperature, ) from homeassistant.core import HomeAssistant @@ -34,8 +32,6 @@ from tests.components.common import ( target_entities, ) -_TEMPERATURE_CONDITION_OPTIONS = {"unit": UnitOfTemperature.CELSIUS} - @pytest.fixture async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]: @@ -275,7 +271,7 @@ async def test_climate_attribute_condition_behavior_all( "climate.target_temperature", HVACMode.AUTO, ATTR_TEMPERATURE, - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ], ) @@ -319,7 +315,7 @@ async def test_climate_numerical_condition_behavior_any( "climate.target_temperature", HVACMode.AUTO, ATTR_TEMPERATURE, - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ], ) @@ -365,12 +361,39 @@ async def test_climate_numerical_condition_unit_conversion(hass: HomeAssistant) } ], numerical_condition_options=[ - {CONF_ABOVE: 75, CONF_BELOW: 90, "unit": UnitOfTemperature.FAHRENHEIT}, - {CONF_ABOVE: 24, CONF_BELOW: 30, "unit": UnitOfTemperature.CELSIUS}, + { + "threshold": { + "type": "between", + "value_min": { + "number": 75, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + "value_max": { + "number": 90, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + } + }, + { + "threshold": { + "type": "between", + "value_min": { + "number": 24, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + "value_max": { + "number": 30, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + } + }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ diff --git a/tests/components/common.py b/tests/components/common.py index f3b0a0a0b7b..8611cd30333 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -14,8 +14,6 @@ from homeassistant.const import ( ATTR_FLOOR_ID, ATTR_LABEL_ID, ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, CONF_CONDITION, CONF_ENTITY_ID, CONF_OPTIONS, @@ -1300,6 +1298,7 @@ def parametrize_numerical_condition_above_below_any( *, device_class: str, condition_options: dict[str, Any] | None = None, + threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below threshold test cases for numerical conditions. @@ -1315,7 +1314,13 @@ def parametrize_numerical_condition_above_below_any( return [ *parametrize_condition_states_any( condition=condition, - condition_options={CONF_ABOVE: 20, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "above", "value": {"number": 20}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("21", unit_attributes), ("50", unit_attributes), @@ -1330,7 +1335,13 @@ def parametrize_numerical_condition_above_below_any( ), *parametrize_condition_states_any( condition=condition, - condition_options={CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "below", "value": {"number": 80}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("0", unit_attributes), ("50", unit_attributes), @@ -1345,7 +1356,17 @@ def parametrize_numerical_condition_above_below_any( ), *parametrize_condition_states_any( condition=condition, - condition_options={CONF_ABOVE: 20, CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + }, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("21", unit_attributes), ("50", unit_attributes), @@ -1367,6 +1388,7 @@ def parametrize_numerical_condition_above_below_all( *, device_class: str, condition_options: dict[str, Any] | None = None, + threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below threshold test cases for numerical conditions with 'all' behavior. @@ -1382,7 +1404,13 @@ def parametrize_numerical_condition_above_below_all( return [ *parametrize_condition_states_all( condition=condition, - condition_options={CONF_ABOVE: 20, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "above", "value": {"number": 20}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("21", unit_attributes), ("50", unit_attributes), @@ -1397,7 +1425,13 @@ def parametrize_numerical_condition_above_below_all( ), *parametrize_condition_states_all( condition=condition, - condition_options={CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "below", "value": {"number": 80}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("0", unit_attributes), ("50", unit_attributes), @@ -1412,7 +1446,17 @@ def parametrize_numerical_condition_above_below_all( ), *parametrize_condition_states_all( condition=condition, - condition_options={CONF_ABOVE: 20, CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + }, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("21", unit_attributes), ("50", unit_attributes), @@ -1436,6 +1480,7 @@ def parametrize_numerical_attribute_condition_above_below_any( *, condition_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, + threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below threshold test cases for attribute-based numerical conditions. @@ -1448,7 +1493,13 @@ def parametrize_numerical_attribute_condition_above_below_any( return [ *parametrize_condition_states_any( condition=condition, - condition_options={CONF_ABOVE: 20, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "above", "value": {"number": 20}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 21} | unit_attributes), (state, {attribute: 50} | unit_attributes), @@ -1463,7 +1514,13 @@ def parametrize_numerical_attribute_condition_above_below_any( ), *parametrize_condition_states_any( condition=condition, - condition_options={CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "below", "value": {"number": 80}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 0} | unit_attributes), (state, {attribute: 50} | unit_attributes), @@ -1478,7 +1535,17 @@ def parametrize_numerical_attribute_condition_above_below_any( ), *parametrize_condition_states_any( condition=condition, - condition_options={CONF_ABOVE: 20, CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + }, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 21} | unit_attributes), (state, {attribute: 50} | unit_attributes), @@ -1502,6 +1569,7 @@ def parametrize_numerical_attribute_condition_above_below_all( *, condition_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, + threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below threshold test cases for attribute-based numerical conditions with 'all' behavior. @@ -1514,7 +1582,13 @@ def parametrize_numerical_attribute_condition_above_below_all( return [ *parametrize_condition_states_all( condition=condition, - condition_options={CONF_ABOVE: 20, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "above", "value": {"number": 20}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 21} | unit_attributes), (state, {attribute: 50} | unit_attributes), @@ -1529,7 +1603,13 @@ def parametrize_numerical_attribute_condition_above_below_all( ), *parametrize_condition_states_all( condition=condition, - condition_options={CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "below", "value": {"number": 80}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 0} | unit_attributes), (state, {attribute: 50} | unit_attributes), @@ -1544,7 +1624,17 @@ def parametrize_numerical_attribute_condition_above_below_all( ), *parametrize_condition_states_all( condition=condition, - condition_options={CONF_ABOVE: 20, CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + }, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 21} | unit_attributes), (state, {attribute: 50} | unit_attributes), diff --git a/tests/components/power/test_condition.py b/tests/components/power/test_condition.py index 6fb9a76a975..b469d7ac2b6 100644 --- a/tests/components/power/test_condition.py +++ b/tests/components/power/test_condition.py @@ -4,12 +4,7 @@ from typing import Any import pytest -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, - UnitOfPower, -) +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower from homeassistant.core import HomeAssistant from tests.components.common import ( @@ -24,8 +19,6 @@ from tests.components.common import ( target_entities, ) -_POWER_CONDITION_OPTIONS = {"unit": UnitOfPower.WATT} - @pytest.fixture async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: @@ -60,7 +53,7 @@ async def test_power_conditions_gated_by_labs_flag( parametrize_numerical_condition_above_below_any( "power.is_value", device_class="power", - condition_options=_POWER_CONDITION_OPTIONS, + threshold_unit=UnitOfPower.WATT, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, ), ) @@ -97,7 +90,7 @@ async def test_power_sensor_condition_behavior_any( parametrize_numerical_condition_above_below_all( "power.is_value", device_class="power", - condition_options=_POWER_CONDITION_OPTIONS, + threshold_unit=UnitOfPower.WATT, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, ), ) @@ -134,7 +127,7 @@ async def test_power_sensor_condition_behavior_all( parametrize_numerical_condition_above_below_any( "power.is_value", device_class="power", - condition_options=_POWER_CONDITION_OPTIONS, + threshold_unit=UnitOfPower.WATT, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, ), ) @@ -171,7 +164,7 @@ async def test_power_number_condition_behavior_any( parametrize_numerical_condition_above_below_all( "power.is_value", device_class="power", - condition_options=_POWER_CONDITION_OPTIONS, + threshold_unit=UnitOfPower.WATT, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, ), ) @@ -230,12 +223,39 @@ async def test_power_condition_unit_conversion_sensor( } ], numerical_condition_options=[ - {CONF_ABOVE: 0.2, CONF_BELOW: 0.8, "unit": UnitOfPower.KILO_WATT}, - {CONF_ABOVE: 200, CONF_BELOW: 800, "unit": UnitOfPower.WATT}, + { + "threshold": { + "type": "between", + "value_min": { + "number": 0.2, + "unit_of_measurement": UnitOfPower.KILO_WATT, + }, + "value_max": { + "number": 0.8, + "unit_of_measurement": UnitOfPower.KILO_WATT, + }, + } + }, + { + "threshold": { + "type": "between", + "value_min": { + "number": 200, + "unit_of_measurement": UnitOfPower.WATT, + }, + "value_max": { + "number": 800, + "unit_of_measurement": UnitOfPower.WATT, + }, + } + }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ diff --git a/tests/components/temperature/test_condition.py b/tests/components/temperature/test_condition.py index 9faafd73a83..96199ea2c88 100644 --- a/tests/components/temperature/test_condition.py +++ b/tests/components/temperature/test_condition.py @@ -6,12 +6,7 @@ import pytest from homeassistant.components.climate import HVACMode from homeassistant.components.weather import ATTR_WEATHER_TEMPERATURE_UNIT -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, - UnitOfTemperature, -) +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant from tests.components.common import ( @@ -28,7 +23,6 @@ from tests.components.common import ( target_entities, ) -_TEMPERATURE_CONDITION_OPTIONS = {"unit": UnitOfTemperature.CELSIUS} _WEATHER_UNIT_ATTRIBUTES = {ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.CELSIUS} @@ -77,7 +71,7 @@ async def test_temperature_conditions_gated_by_labs_flag( parametrize_numerical_condition_above_below_any( "temperature.is_value", device_class="temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ), ) @@ -114,7 +108,7 @@ async def test_temperature_sensor_condition_behavior_any( parametrize_numerical_condition_above_below_all( "temperature.is_value", device_class="temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ), ) @@ -152,7 +146,7 @@ async def test_temperature_sensor_condition_behavior_all( "temperature.is_value", HVACMode.AUTO, "current_temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ) async def test_temperature_climate_condition_behavior_any( @@ -189,7 +183,7 @@ async def test_temperature_climate_condition_behavior_any( "temperature.is_value", HVACMode.AUTO, "current_temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ) async def test_temperature_climate_condition_behavior_all( @@ -226,7 +220,7 @@ async def test_temperature_climate_condition_behavior_all( "temperature.is_value", "eco", "current_temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ) async def test_temperature_water_heater_condition_behavior_any( @@ -263,7 +257,7 @@ async def test_temperature_water_heater_condition_behavior_any( "temperature.is_value", "eco", "current_temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ) async def test_temperature_water_heater_condition_behavior_all( @@ -300,7 +294,7 @@ async def test_temperature_water_heater_condition_behavior_all( "temperature.is_value", "sunny", "temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes=_WEATHER_UNIT_ATTRIBUTES, ), ) @@ -338,7 +332,7 @@ async def test_temperature_weather_condition_behavior_any( "temperature.is_value", "sunny", "temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes=_WEATHER_UNIT_ATTRIBUTES, ), ) @@ -397,12 +391,39 @@ async def test_temperature_condition_unit_conversion_sensor( } ], numerical_condition_options=[ - {CONF_ABOVE: 75, CONF_BELOW: 90, "unit": UnitOfTemperature.FAHRENHEIT}, - {CONF_ABOVE: 24, CONF_BELOW: 30, "unit": UnitOfTemperature.CELSIUS}, + { + "threshold": { + "type": "between", + "value_min": { + "number": 75, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + "value_max": { + "number": 90, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + } + }, + { + "threshold": { + "type": "between", + "value_min": { + "number": 24, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + "value_max": { + "number": 30, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + } + }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ @@ -448,12 +469,39 @@ async def test_temperature_condition_unit_conversion_climate( {"state": HVACMode.AUTO, "attributes": {"current_temperature": 20}} ], numerical_condition_options=[ - {CONF_ABOVE: 75, CONF_BELOW: 90, "unit": UnitOfTemperature.FAHRENHEIT}, - {CONF_ABOVE: 24, CONF_BELOW: 30, "unit": UnitOfTemperature.CELSIUS}, + { + "threshold": { + "type": "between", + "value_min": { + "number": 75, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + "value_max": { + "number": 90, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + } + }, + { + "threshold": { + "type": "between", + "value_min": { + "number": 24, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + "value_max": { + "number": 30, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + } + }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ diff --git a/tests/components/water_heater/test_condition.py b/tests/components/water_heater/test_condition.py index 7c54c039eb3..f4965ec70b3 100644 --- a/tests/components/water_heater/test_condition.py +++ b/tests/components/water_heater/test_condition.py @@ -15,8 +15,6 @@ from homeassistant.components.water_heater import ( from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, STATE_OFF, STATE_ON, UnitOfTemperature, @@ -37,8 +35,6 @@ from tests.components.common import ( target_entities, ) -_TEMPERATURE_CONDITION_OPTIONS = {"unit": UnitOfTemperature.CELSIUS} - _ALL_STATES = [ STATE_ECO, STATE_ELECTRIC, @@ -205,7 +201,7 @@ async def test_water_heater_state_condition_behavior_all( "water_heater.is_target_temperature", "eco", ATTR_TEMPERATURE, - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ], ) @@ -244,7 +240,7 @@ async def test_water_heater_numerical_condition_behavior_any( "water_heater.is_target_temperature", "eco", ATTR_TEMPERATURE, - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ], ) @@ -292,12 +288,39 @@ async def test_water_heater_numerical_condition_unit_conversion( } ], numerical_condition_options=[ - {CONF_ABOVE: 120, CONF_BELOW: 140, "unit": UnitOfTemperature.FAHRENHEIT}, - {CONF_ABOVE: 49, CONF_BELOW: 60, "unit": UnitOfTemperature.CELSIUS}, + { + "threshold": { + "type": "between", + "value_min": { + "number": 120, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + "value_max": { + "number": 140, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + } + }, + { + "threshold": { + "type": "between", + "value_min": { + "number": 49, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + "value_max": { + "number": 60, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + } + }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 081a2b4b70b..f454d51c48f 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,6 +1,7 @@ """Test the condition helper.""" from collections.abc import Mapping +from contextlib import AbstractContextManager, nullcontext as does_not_raise from datetime import timedelta import io from typing import Any @@ -21,8 +22,6 @@ from homeassistant.components.system_health import DOMAIN as SYSTEM_HEALTH_DOMAI from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, @@ -50,7 +49,6 @@ from homeassistant.helpers.condition import ( ATTR_BEHAVIOR, BEHAVIOR_ALL, BEHAVIOR_ANY, - CONF_UNIT, Condition, ConditionChecker, EntityNumericalConditionWithUnitBase, @@ -3059,19 +3057,69 @@ async def _setup_numerical_condition( ("condition_options", "state_value", "expected"), [ # above only - ({CONF_ABOVE: 50}, "75", True), - ({CONF_ABOVE: 50}, "50", False), - ({CONF_ABOVE: 50}, "25", False), + ({"threshold": {"type": "above", "value": {"number": 50}}}, "75", True), + ({"threshold": {"type": "above", "value": {"number": 50}}}, "50", False), + ({"threshold": {"type": "above", "value": {"number": 50}}}, "25", False), # below only - ({CONF_BELOW: 50}, "25", True), - ({CONF_BELOW: 50}, "50", False), - ({CONF_BELOW: 50}, "75", False), + ({"threshold": {"type": "below", "value": {"number": 50}}}, "25", True), + ({"threshold": {"type": "below", "value": {"number": 50}}}, "50", False), + ({"threshold": {"type": "below", "value": {"number": 50}}}, "75", False), # above and below (range) - ({CONF_ABOVE: 20, CONF_BELOW: 80}, "50", True), - ({CONF_ABOVE: 20, CONF_BELOW: 80}, "20", False), - ({CONF_ABOVE: 20, CONF_BELOW: 80}, "80", False), - ({CONF_ABOVE: 20, CONF_BELOW: 80}, "10", False), - ({CONF_ABOVE: 20, CONF_BELOW: 80}, "90", False), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "50", + True, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "20", + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "80", + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "10", + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "90", + False, + ), ], ) async def test_numerical_condition_thresholds( @@ -3101,7 +3149,7 @@ async def test_numerical_condition_invalid_state( """Test numerical condition with non-numeric or unavailable state values.""" test = await _setup_numerical_condition( hass, - condition_options={CONF_ABOVE: 50}, + condition_options={"threshold": {"type": "above", "value": {"number": 50}}}, entity_ids="test.entity_1", ) @@ -3116,7 +3164,7 @@ async def test_numerical_condition_attribute_value_source( test = await _setup_numerical_condition( hass, domain_specs={"test": DomainSpec(value_source="brightness")}, - condition_options={CONF_ABOVE: 100}, + condition_options={"threshold": {"type": "above", "value": {"number": 100}}}, entity_ids="test.entity_1", ) @@ -3145,7 +3193,7 @@ async def test_numerical_condition_attribute_value_source_skips_unit_check( test = await _setup_numerical_condition( hass, domain_specs={"test": DomainSpec(value_source="humidity")}, - condition_options={CONF_ABOVE: 50}, + condition_options={"threshold": {"type": "above", "value": {"number": 50}}}, entity_ids="test.entity_1", valid_unit="%", ) @@ -3184,7 +3232,7 @@ async def test_numerical_condition_valid_unit( """Test numerical condition valid_unit filtering.""" test = await _setup_numerical_condition( hass, - condition_options={CONF_ABOVE: 50}, + condition_options={"threshold": {"type": "above", "value": {"number": 50}}}, entity_ids="test.entity_1", valid_unit=valid_unit, ) @@ -3209,7 +3257,10 @@ async def test_numerical_condition_behavior( """Test numerical condition with behavior any/all.""" test = await _setup_numerical_condition( hass, - condition_options={CONF_ABOVE: 50, ATTR_BEHAVIOR: behavior}, + condition_options={ + "threshold": {"type": "above", "value": {"number": 50}}, + ATTR_BEHAVIOR: behavior, + }, entity_ids=["test.entity_1", "test.entity_2"], ) @@ -3253,16 +3304,17 @@ async def test_numerical_condition_schema_requires_above_or_below( @pytest.mark.parametrize( - ("above", "below"), + ("above", "below", "expected_result"), [ - (10.0, 10.0), - (20.0, 10.0), + (10.0, 10.0, does_not_raise()), + (20.0, 10.0, pytest.raises(vol.Invalid, match="must not be greater")), ], ) async def test_numerical_condition_schema_above_must_be_less_than_below( hass: HomeAssistant, above: float, below: float, + expected_result: AbstractContextManager, ) -> None: """Test numerical condition schema rejects above >= below.""" condition_cls = make_entity_numerical_condition({"test": DomainSpec()}) @@ -3280,9 +3332,15 @@ async def test_numerical_condition_schema_above_must_be_less_than_below( config: dict[str, Any] = { CONF_CONDITION: "test", CONF_TARGET: {CONF_ENTITY_ID: "test.entity_1"}, - CONF_OPTIONS: {CONF_ABOVE: above, CONF_BELOW: below}, + CONF_OPTIONS: { + "threshold": { + "type": "between", + "value_min": {"number": above}, + "value_max": {"number": below}, + } + }, } - with pytest.raises(vol.Invalid, match="can never be above"): + with expected_result: await async_validate_condition_config(hass, config) @@ -3329,42 +3387,102 @@ async def _setup_numerical_condition_with_unit( [ # above in °F, state in °C (base unit) # 75°F ≈ 23.89°C, so 25°C > 23.89°C → True - ({CONF_ABOVE: 75, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "25", True), + ( + { + "threshold": { + "type": "above", + "value": {"number": 75, "unit_of_measurement": "°F"}, + } + }, + "25", + True, + ), # 75°F ≈ 23.89°C, so 20°C < 23.89°C → False - ({CONF_ABOVE: 75, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "20", False), + ( + { + "threshold": { + "type": "above", + "value": {"number": 75, "unit_of_measurement": "°F"}, + } + }, + "20", + False, + ), # below in °F, state in °C # 70°F ≈ 21.11°C, so 20°C < 21.11°C → True - ({CONF_BELOW: 70, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "20", True), + ( + { + "threshold": { + "type": "below", + "value": {"number": 70, "unit_of_measurement": "°F"}, + } + }, + "20", + True, + ), # 70°F ≈ 21.11°C, so 25°C > 21.11°C → False - ({CONF_BELOW: 70, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "25", False), + ( + { + "threshold": { + "type": "below", + "value": {"number": 70, "unit_of_measurement": "°F"}, + } + }, + "25", + False, + ), # above in °C (same as base), state in °C - ({CONF_ABOVE: 20, CONF_UNIT: UnitOfTemperature.CELSIUS}, "25", True), - ({CONF_ABOVE: 20, CONF_UNIT: UnitOfTemperature.CELSIUS}, "15", False), + ( + { + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "°C"}, + } + }, + "25", + True, + ), + ( + { + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "°C"}, + } + }, + "15", + False, + ), # range with unit conversion # 60°F ≈ 15.56°C, 80°F ≈ 26.67°C ( { - CONF_ABOVE: 60, - CONF_BELOW: 80, - CONF_UNIT: UnitOfTemperature.FAHRENHEIT, + "threshold": { + "type": "between", + "value_min": {"number": 60, "unit_of_measurement": "°F"}, + "value_max": {"number": 80, "unit_of_measurement": "°F"}, + } }, "20", True, ), ( { - CONF_ABOVE: 60, - CONF_BELOW: 80, - CONF_UNIT: UnitOfTemperature.FAHRENHEIT, + "threshold": { + "type": "between", + "value_min": {"number": 60, "unit_of_measurement": "°F"}, + "value_max": {"number": 80, "unit_of_measurement": "°F"}, + } }, "10", False, ), ( { - CONF_ABOVE: 60, - CONF_BELOW: 80, - CONF_UNIT: UnitOfTemperature.FAHRENHEIT, + "threshold": { + "type": "between", + "value_min": {"number": 60, "unit_of_measurement": "°F"}, + "value_max": {"number": 80, "unit_of_measurement": "°F"}, + } }, "30", False, @@ -3399,8 +3517,7 @@ async def test_numerical_condition_with_unit_entity_reference( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: "sensor.temp_limit", - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": {"type": "above", "value": {"entity": "sensor.temp_limit"}}, }, entity_ids="test.entity_1", ) @@ -3435,8 +3552,7 @@ async def test_numerical_condition_with_unit_entity_reference_incompatible_unit( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: "sensor.bad_limit", - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": {"type": "above", "value": {"entity": "sensor.bad_limit"}}, }, entity_ids="test.entity_1", ) @@ -3462,8 +3578,10 @@ async def test_numerical_condition_with_unit_tracked_value_conversion( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: 20, - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "°C"}, + } }, entity_ids="test.entity_1", ) @@ -3495,8 +3613,10 @@ async def test_numerical_condition_with_unit_attribute_value_source( "test": NumericalDomainSpec(value_source="temperature"), }, condition_options={ - CONF_ABOVE: 75, - CONF_UNIT: UnitOfTemperature.FAHRENHEIT, + "threshold": { + "type": "above", + "value": {"number": 75, "unit_of_measurement": "°F"}, + }, }, entity_ids="test.entity_1", ) @@ -3557,8 +3677,10 @@ async def test_numerical_condition_with_unit_get_entity_unit_override( CONF_CONDITION: "test", CONF_TARGET: {CONF_ENTITY_ID: ["test.entity_1"]}, CONF_OPTIONS: { - CONF_ABOVE: 20, - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "°C"}, + } }, } config = await async_validate_condition_config(hass, config) @@ -3598,8 +3720,10 @@ async def test_numerical_condition_with_unit_schema_accepts_valid_units( CONF_CONDITION: "test", CONF_TARGET: {CONF_ENTITY_ID: "test.entity_1"}, CONF_OPTIONS: { - CONF_ABOVE: 20, - CONF_UNIT: UnitOfTemperature.FAHRENHEIT, + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "°F"}, + } }, } result = await async_validate_condition_config(hass, config) @@ -3629,8 +3753,10 @@ async def test_numerical_condition_with_unit_schema_rejects_invalid_units( CONF_CONDITION: "test", CONF_TARGET: {CONF_ENTITY_ID: "test.entity_1"}, CONF_OPTIONS: { - CONF_ABOVE: 20, - CONF_UNIT: "%", + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "%"}, + } }, } with pytest.raises(vol.Invalid): @@ -3648,8 +3774,10 @@ async def test_numerical_condition_with_unit_invalid_state( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: 50, - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": { + "type": "above", + "value": {"number": 50, "unit_of_measurement": "°C"}, + }, }, entity_ids="test.entity_1", ) @@ -3669,8 +3797,7 @@ async def test_numerical_condition_with_unit_missing_entity_reference( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: "sensor.nonexistent", - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": {"type": "above", "value": {"entity": "sensor.nonexistent"}} }, entity_ids="test.entity_1", ) @@ -3699,9 +3826,11 @@ async def test_numerical_condition_with_unit_behavior( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: 50, ATTR_BEHAVIOR: behavior, - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": { + "type": "above", + "value": {"number": 50, "unit_of_measurement": "°C"}, + }, }, entity_ids=["test.entity_1", "test.entity_2"], ) From 98379c9642b4f2fef43459ba8612f649dad95a6b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Mar 2026 00:33:48 +0100 Subject: [PATCH 005/138] Improve cloud action naming consistency (#166516) --- homeassistant/components/cloud/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index d642f1df682..6cb6b505b84 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -75,11 +75,11 @@ "services": { "remote_connect": { "description": "Makes the instance UI accessible from outside of the local network by enabling your Home Assistant Cloud connection.", - "name": "Enable remote access" + "name": "Enable Home Assistant Cloud remote access" }, "remote_disconnect": { "description": "Disconnects the instance UI from Home Assistant Cloud. This disables access to it from outside your local network.", - "name": "Disable remote access" + "name": "Disable Home Assistant Cloud remote access" } }, "system_health": { From 4211686c0709825106f5064dd0c83878b840f2c8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:14:19 +0100 Subject: [PATCH 006/138] Improve script action naming consistency (#166517) --- homeassistant/components/script/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index cde7a962112..ea826d3eaa6 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -51,19 +51,19 @@ "services": { "reload": { "description": "Reloads all the available scripts.", - "name": "[%key:common::action::reload%]" + "name": "Reload scripts" }, "toggle": { "description": "Starts a script if it isn't running, stops it otherwise.", - "name": "[%key:common::action::toggle%]" + "name": "Toggle script" }, "turn_off": { "description": "Stops a running script.", - "name": "[%key:common::action::turn_off%]" + "name": "Turn off script" }, "turn_on": { "description": "Runs the sequence of actions defined in a script.", - "name": "[%key:common::action::turn_on%]" + "name": "Turn on script" } }, "title": "Script" From 5d4880164522a33411ebf6bfe62d1a4d02f2bf3c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:13:56 +0100 Subject: [PATCH 007/138] Improve valve action naming consistency (#166521) --- homeassistant/components/valve/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index e781829b781..10e5e302eba 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -25,11 +25,11 @@ "services": { "close_valve": { "description": "Closes a valve.", - "name": "[%key:common::action::close%]" + "name": "Close valve" }, "open_valve": { "description": "Opens a valve.", - "name": "[%key:common::action::open%]" + "name": "Open valve" }, "set_valve_position": { "description": "Moves a valve to a specific position.", @@ -39,15 +39,15 @@ "name": "Position" } }, - "name": "Set position" + "name": "Set valve position" }, "stop_valve": { - "description": "Stops the valve movement.", - "name": "[%key:common::action::stop%]" + "description": "Stops a valve.", + "name": "Stop valve" }, "toggle": { "description": "Toggles a valve open/closed.", - "name": "[%key:common::action::toggle%]" + "name": "Toggle valve" } }, "title": "Valve" From 744563c7a799f5c9af103f1569cbe800e56d73d4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 00:51:14 +0100 Subject: [PATCH 008/138] Speed up trigger tests (#166522) --- tests/components/air_quality/test_trigger.py | 23 +-- .../alarm_control_panel/test_trigger.py | 8 +- .../assist_satellite/test_trigger.py | 8 +- tests/components/button/test_trigger.py | 20 +-- tests/components/climate/test_trigger.py | 14 +- tests/components/common.py | 134 +++++++++++------- tests/components/counter/test_trigger.py | 26 ++-- tests/components/cover/test_trigger.py | 8 +- .../components/device_tracker/test_trigger.py | 8 +- tests/components/door/test_trigger.py | 14 +- tests/components/event/test_trigger.py | 20 +-- tests/components/fan/test_trigger.py | 8 +- tests/components/garage_door/test_trigger.py | 14 +- tests/components/gate/test_trigger.py | 8 +- tests/components/humidifier/test_trigger.py | 14 +- tests/components/humidity/test_trigger.py | 28 +--- tests/components/illuminance/test_trigger.py | 20 +-- tests/components/lawn_mower/test_trigger.py | 8 +- tests/components/light/test_trigger.py | 16 +-- tests/components/lock/test_trigger.py | 8 +- tests/components/media_player/test_trigger.py | 8 +- tests/components/moisture/test_trigger.py | 22 +-- tests/components/motion/test_trigger.py | 8 +- tests/components/occupancy/test_trigger.py | 8 +- tests/components/person/test_trigger.py | 8 +- tests/components/power/test_trigger.py | 14 +- tests/components/remote/test_trigger.py | 8 +- tests/components/scene/test_trigger.py | 20 +-- tests/components/schedule/test_trigger.py | 33 ++--- tests/components/select/test_trigger.py | 38 +++-- tests/components/siren/test_trigger.py | 8 +- tests/components/switch/test_trigger.py | 29 ++-- tests/components/temperature/test_trigger.py | 64 +++------ tests/components/text/test_trigger.py | 36 ++--- tests/components/update/test_trigger.py | 8 +- tests/components/vacuum/test_trigger.py | 8 +- tests/components/water_heater/test_trigger.py | 14 +- tests/components/window/test_trigger.py | 14 +- 38 files changed, 233 insertions(+), 522 deletions(-) diff --git a/tests/components/air_quality/test_trigger.py b/tests/components/air_quality/test_trigger.py index 8dcddddedef..5546f5d23ca 100644 --- a/tests/components/air_quality/test_trigger.py +++ b/tests/components/air_quality/test_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -153,7 +153,6 @@ async def test_air_quality_triggers_gated_by_labs_flag( ) async def test_air_quality_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -165,7 +164,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_any( """Test air quality triggers fire for binary_sensor entities with gas, CO, and smoke device classes.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -234,7 +232,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_any( ) async def test_air_quality_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -246,7 +243,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_first( """Test air quality trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -315,7 +311,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_first( ) async def test_air_quality_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -327,7 +322,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_last( """Test air quality trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -497,7 +491,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_last( ) async def test_air_quality_trigger_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -509,7 +502,6 @@ async def test_air_quality_trigger_sensor_behavior_any( """Test air quality trigger fires for sensor entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -607,7 +599,6 @@ async def test_air_quality_trigger_sensor_behavior_any( ) async def test_air_quality_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, @@ -619,7 +610,6 @@ async def test_air_quality_trigger_sensor_crossed_threshold_behavior_first( """Test air quality 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, @@ -717,7 +707,6 @@ async def test_air_quality_trigger_sensor_crossed_threshold_behavior_first( ) async def test_air_quality_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, @@ -729,7 +718,6 @@ async def test_air_quality_trigger_sensor_crossed_threshold_behavior_last( """Test air quality 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, @@ -743,9 +731,9 @@ async def test_air_quality_trigger_sensor_crossed_threshold_behavior_last( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_air_quality_trigger_unit_conversion_co_ppm_to_ugm3( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test CO crossed_threshold trigger converts sensor value from ppm to μg/m³.""" + calls: list[str] = [] entity_id = "sensor.test_co" # Sensor reports in ppm, trigger threshold is in μg/m³ (fixed unit for CO) @@ -770,6 +758,7 @@ async def test_air_quality_trigger_unit_conversion_co_ppm_to_ugm3( } }, {CONF_ENTITY_ID: [entity_id]}, + calls, ) # 0.5 ppm ≈ 582 μg/m³, which is below 1000 μg/m³ - should NOT trigger @@ -782,7 +771,7 @@ async def test_air_quality_trigger_unit_conversion_co_ppm_to_ugm3( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # 1 ppm ≈ 1164 μg/m³, which is above 1000 μg/m³ - should trigger hass.states.async_set( @@ -794,5 +783,5 @@ async def test_air_quality_trigger_unit_conversion_co_ppm_to_ugm3( }, ) await hass.async_block_till_done() - assert len(service_calls) == 1 - service_calls.clear() + assert len(calls) == 1 + calls.clear() diff --git a/tests/components/alarm_control_panel/test_trigger.py b/tests/components/alarm_control_panel/test_trigger.py index b6403bccd17..8aeca55ea63 100644 --- a/tests/components/alarm_control_panel/test_trigger.py +++ b/tests/components/alarm_control_panel/test_trigger.py @@ -9,7 +9,7 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, ) from homeassistant.const import ATTR_SUPPORTED_FEATURES -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -124,7 +124,6 @@ async def test_alarm_control_panel_triggers_gated_by_labs_flag( ) async def test_alarm_control_panel_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_alarm_control_panels: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -136,7 +135,6 @@ async def test_alarm_control_panel_state_trigger_behavior_any( """Test that the alarm control panel state trigger fires when any alarm control panel state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_alarm_control_panels, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -222,7 +220,6 @@ async def test_alarm_control_panel_state_trigger_behavior_any( ) async def test_alarm_control_panel_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_alarm_control_panels: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -234,7 +231,6 @@ async def test_alarm_control_panel_state_trigger_behavior_first( """Test that the alarm control panel state trigger fires when the first alarm control panel changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_alarm_control_panels, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -320,7 +316,6 @@ async def test_alarm_control_panel_state_trigger_behavior_first( ) async def test_alarm_control_panel_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_alarm_control_panels: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -332,7 +327,6 @@ async def test_alarm_control_panel_state_trigger_behavior_last( """Test that the alarm_control_panel state trigger fires when the last alarm_control_panel changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_alarm_control_panels, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/assist_satellite/test_trigger.py b/tests/components/assist_satellite/test_trigger.py index 085659e9ef0..dd23714375c 100644 --- a/tests/components/assist_satellite/test_trigger.py +++ b/tests/components/assist_satellite/test_trigger.py @@ -5,7 +5,7 @@ from typing import Any import pytest from homeassistant.components.assist_satellite.entity import AssistSatelliteState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -74,7 +74,6 @@ async def test_assist_satellite_triggers_gated_by_labs_flag( ) async def test_assist_satellite_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_assist_satellites: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -86,7 +85,6 @@ async def test_assist_satellite_state_trigger_behavior_any( """Test that the assist satellite state trigger fires when any assist satellite state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_assist_satellites, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -129,7 +127,6 @@ async def test_assist_satellite_state_trigger_behavior_any( ) async def test_assist_satellite_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_assist_satellites: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -141,7 +138,6 @@ async def test_assist_satellite_state_trigger_behavior_first( """Test that the assist satellite state trigger fires when the first assist satellite changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_assist_satellites, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -184,7 +180,6 @@ async def test_assist_satellite_state_trigger_behavior_first( ) async def test_assist_satellite_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_assist_satellites: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -196,7 +191,6 @@ async def test_assist_satellite_state_trigger_behavior_last( """Test that the assist_satellite state trigger fires when the last assist_satellite changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_assist_satellites, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/button/test_trigger.py b/tests/components/button/test_trigger.py index 8e910357a61..6b9edfbc1ac 100644 --- a/tests/components/button/test_trigger.py +++ b/tests/components/button/test_trigger.py @@ -2,8 +2,8 @@ import pytest -from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -147,7 +147,6 @@ async def test_button_triggers_gated_by_labs_flag( ) async def test_button_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_buttons: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -156,6 +155,7 @@ async def test_button_state_trigger( states: list[TriggerStateDescription], ) -> None: """Test that the button state trigger fires when targeted button state changes.""" + calls: list[str] = [] other_entity_ids = set(target_buttons["included_entities"]) - {entity_id} # Set all buttons, including the tested button, to the initial state @@ -163,20 +163,20 @@ async def test_button_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other buttons also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() diff --git a/tests/components/climate/test_trigger.py b/tests/components/climate/test_trigger.py index 1fac3f5f382..cb6813b913a 100644 --- a/tests/components/climate/test_trigger.py +++ b/tests/components/climate/test_trigger.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_TARGET, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import async_validate_trigger_config from tests.components.common import ( @@ -157,7 +157,6 @@ async def test_climate_trigger_validation( ) async def test_climate_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -169,7 +168,6 @@ async def test_climate_state_trigger_behavior_any( """Test that the climate state trigger fires when any climate state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -225,7 +223,6 @@ async def test_climate_state_trigger_behavior_any( ) async def test_climate_state_attribute_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -237,7 +234,6 @@ async def test_climate_state_attribute_trigger_behavior_any( """Test that the climate state trigger fires when any climate state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -285,7 +281,6 @@ async def test_climate_state_attribute_trigger_behavior_any( ) async def test_climate_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entities_in_target: int, @@ -297,7 +292,6 @@ async def test_climate_state_trigger_behavior_first( """Test that the climate state trigger fires when the first climate changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -344,7 +338,6 @@ async def test_climate_state_trigger_behavior_first( ) async def test_climate_state_attribute_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -356,7 +349,6 @@ async def test_climate_state_attribute_trigger_behavior_first( """Test that the climate state trigger fires when the first climate state changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -404,7 +396,6 @@ async def test_climate_state_attribute_trigger_behavior_first( ) async def test_climate_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entities_in_target: int, @@ -416,7 +407,6 @@ async def test_climate_state_trigger_behavior_last( """Test that the climate state trigger fires when the last climate changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -463,7 +453,6 @@ async def test_climate_state_trigger_behavior_last( ) async def test_climate_state_attribute_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -475,7 +464,6 @@ async def test_climate_state_attribute_trigger_behavior_last( """Test that the climate state trigger fires when the last climate state changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/common.py b/tests/components/common.py index 8611cd30333..7b2459dfc9a 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -4,6 +4,7 @@ from collections.abc import Iterable import copy from enum import StrEnum import itertools +import logging from typing import Any, TypedDict import pytest @@ -22,7 +23,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -34,7 +35,8 @@ from homeassistant.helpers.condition import ( ConditionCheckerTypeOptional, async_from_config as async_condition_from_config, ) -from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.helpers.trigger import async_initialize_triggers +from homeassistant.helpers.typing import UNDEFINED, TemplateVarsType, UndefinedType from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, mock_device_registry @@ -931,30 +933,32 @@ async def arm_trigger( trigger: str, trigger_options: dict[str, Any] | None, trigger_target: dict, + calls: list[str], ) -> None: - """Arm the specified trigger, call service test.automation when it triggers.""" - - # Local include to avoid importing the automation component unnecessarily - from homeassistant.components import automation # noqa: PLC0415 - + """Arm the specified trigger and record fired entity_ids in calls when it triggers.""" options = {CONF_OPTIONS: {**trigger_options}} if trigger_options is not None else {} - await async_setup_component( + trigger_config = { + CONF_PLATFORM: trigger, + CONF_TARGET: {**trigger_target}, + } | options + + @callback + def action(run_variables: TemplateVarsType, context: Context | None = None) -> None: + calls.append(run_variables["trigger"]["entity_id"]) + + logger = logging.getLogger(__name__) + + def log_cb(level: int, msg: str, **kwargs: Any) -> None: + logger._log(level, "%s", msg, **kwargs) + + await async_initialize_triggers( hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - CONF_PLATFORM: trigger, - CONF_TARGET: {**trigger_target}, - } - | options, - "action": { - "service": "test.automation", - "data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"}, - }, - } - }, + [trigger_config], + action, + domain="test", + name="test_trigger", + log_cb=log_cb, ) @@ -1044,7 +1048,25 @@ async def assert_trigger_gated_by_labs_flag( ) -> None: """Helper to check that a trigger is gated by the labs flag.""" - await arm_trigger(hass, trigger, None, {ATTR_LABEL_ID: "test_label"}) + # Local include to avoid importing the automation component unnecessarily + from homeassistant.components import automation # noqa: PLC0415 + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + CONF_PLATFORM: trigger, + CONF_TARGET: {ATTR_LABEL_ID: "test_label"}, + }, + "action": { + "service": "test.automation", + }, + } + }, + ) + assert ( "Unnamed automation failed to setup triggers and has been disabled: Trigger " f"'{trigger}' requires the experimental 'New triggers and conditions' " @@ -1157,7 +1179,6 @@ async def assert_condition_behavior_all( async def assert_trigger_behavior_any( hass: HomeAssistant, *, - service_calls: list[ServiceCall], target_entities: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -1167,6 +1188,7 @@ async def assert_trigger_behavior_any( states: list[TriggerStateDescription], ) -> None: """Test trigger fires in mode any.""" + calls: list[str] = [] other_entity_ids = set(target_entities["included_entities"]) - {entity_id} excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id} @@ -1177,17 +1199,17 @@ async def assert_trigger_behavior_any( set_or_remove_state(hass, eid, states[0]["excluded_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, trigger_options, trigger_target_config) + await arm_trigger(hass, trigger, trigger_options, trigger_target_config, calls) for state in states[1:]: excluded_state = state["excluded_state"] included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) @@ -1195,14 +1217,13 @@ async def assert_trigger_behavior_any( for excluded_entity_id in excluded_entity_ids: set_or_remove_state(hass, excluded_entity_id, excluded_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() async def assert_trigger_behavior_first( hass: HomeAssistant, *, - service_calls: list[ServiceCall], target_entities: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -1212,6 +1233,7 @@ async def assert_trigger_behavior_first( states: list[TriggerStateDescription], ) -> None: """Test trigger fires in mode first.""" + calls: list[str] = [] other_entity_ids = set(target_entities["included_entities"]) - {entity_id} excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id} @@ -1223,7 +1245,11 @@ async def assert_trigger_behavior_first( await hass.async_block_till_done() await arm_trigger( - hass, trigger, {"behavior": "first"} | trigger_options, trigger_target_config + hass, + trigger, + {"behavior": "first"} | trigger_options, + trigger_target_config, + calls, ) for state in states[1:]: @@ -1231,10 +1257,10 @@ async def assert_trigger_behavior_first( included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) @@ -1242,13 +1268,12 @@ async def assert_trigger_behavior_first( for excluded_entity_id in excluded_entity_ids: set_or_remove_state(hass, excluded_entity_id, excluded_state) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 async def assert_trigger_behavior_last( hass: HomeAssistant, *, - service_calls: list[ServiceCall], target_entities: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -1258,6 +1283,7 @@ async def assert_trigger_behavior_last( states: list[TriggerStateDescription], ) -> None: """Test trigger fires in mode last.""" + calls: list[str] = [] other_entity_ids = set(target_entities["included_entities"]) - {entity_id} excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id} @@ -1269,7 +1295,11 @@ async def assert_trigger_behavior_last( await hass.async_block_till_done() await arm_trigger( - hass, trigger, {"behavior": "last"} | trigger_options, trigger_target_config + hass, + trigger, + {"behavior": "last"} | trigger_options, + trigger_target_config, + calls, ) for state in states[1:]: @@ -1278,19 +1308,19 @@ async def assert_trigger_behavior_last( for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() for excluded_entity_id in excluded_entity_ids: set_or_remove_state(hass, excluded_entity_id, excluded_state) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 def parametrize_numerical_condition_above_below_any( @@ -1654,7 +1684,6 @@ def parametrize_numerical_attribute_condition_above_below_all( async def assert_trigger_ignores_limit_entities_with_wrong_unit( hass: HomeAssistant, *, - service_calls: list[ServiceCall], trigger: str, trigger_options: dict[str, Any], entity_id: str, @@ -1682,6 +1711,7 @@ async def assert_trigger_ignores_limit_entities_with_wrong_unit( wrong_unit: A unit that the trigger should reject (e.g. "lx"). """ + calls: list[str] = [] # Set up entity in triggering state set_or_remove_state(hass, entity_id, trigger_state) # Set up all limit entities with the wrong unit @@ -1693,14 +1723,16 @@ async def assert_trigger_ignores_limit_entities_with_wrong_unit( ) await hass.async_block_till_done() - await arm_trigger(hass, trigger, trigger_options, {CONF_ENTITY_ID: [entity_id]}) + await arm_trigger( + hass, trigger, trigger_options, {CONF_ENTITY_ID: [entity_id]}, calls + ) # Cycle entity state - should NOT fire (all limit entities have wrong unit) set_or_remove_state(hass, entity_id, reset_state) await hass.async_block_till_done() set_or_remove_state(hass, entity_id, trigger_state) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # Fix limit entities one at a time; trigger should not fire until all are fixed for i, (limit_entity_id, limit_value) in enumerate(limit_entities): @@ -1718,10 +1750,10 @@ async def assert_trigger_ignores_limit_entities_with_wrong_unit( if i < len(limit_entities) - 1: # Not all limits fixed yet - should not fire - assert len(service_calls) == 0 + assert len(calls) == 0 else: # All limits fixed - should fire - assert len(service_calls) == 1 + assert len(calls) == 1 async def assert_numerical_condition_unit_conversion( diff --git a/tests/components/counter/test_trigger.py b/tests/components/counter/test_trigger.py index 43eccabeca4..8ef5df0c106 100644 --- a/tests/components/counter/test_trigger.py +++ b/tests/components/counter/test_trigger.py @@ -10,8 +10,8 @@ from homeassistant.components.counter import ( CONF_MINIMUM, DOMAIN, ) -from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from tests.components.common import ( BasicTriggerStateDescription, @@ -119,7 +119,6 @@ async def test_counter_triggers_gated_by_labs_flag( ) async def test_counter_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_counters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -128,6 +127,7 @@ async def test_counter_state_trigger( states: list[BasicTriggerStateDescription], ) -> None: """Test that the counter decrement and increment triggers fire correctly.""" + calls: list[str] = [] other_entity_ids = set(target_counters["included_entities"]) - {entity_id} # Set all counters, including the tested one, to the initial state @@ -135,23 +135,23 @@ async def test_counter_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other counters also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() @pytest.mark.usefixtures("enable_labs_preview_features") @@ -164,7 +164,6 @@ async def test_counter_state_trigger( ) async def test_counter_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_counters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -176,7 +175,6 @@ async def test_counter_state_trigger_behavior_any( """Test that the counter state trigger fires when any counter state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_counters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -197,7 +195,6 @@ async def test_counter_state_trigger_behavior_any( ) async def test_counter_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_counters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -209,7 +206,6 @@ async def test_counter_state_trigger_behavior_first( """Test that the counter state trigger fires when the first counter changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_counters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -230,7 +226,6 @@ async def test_counter_state_trigger_behavior_first( ) async def test_counter_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_counters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -242,7 +237,6 @@ async def test_counter_state_trigger_behavior_last( """Test that the counter state trigger fires when the last counter changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_counters, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/cover/test_trigger.py b/tests/components/cover/test_trigger.py index 76db0e5c131..667b8cd1c30 100644 --- a/tests/components/cover/test_trigger.py +++ b/tests/components/cover/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState from homeassistant.const import ATTR_DEVICE_CLASS -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -100,7 +100,6 @@ async def test_cover_triggers_gated_by_labs_flag( ) async def test_cover_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -112,7 +111,6 @@ async def test_cover_trigger_behavior_any( """Test cover trigger fires for cover entities with matching device_class.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -174,7 +172,6 @@ async def test_cover_trigger_behavior_any( ) async def test_cover_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -186,7 +183,6 @@ async def test_cover_trigger_behavior_first( """Test cover trigger fires on the first cover state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -248,7 +244,6 @@ async def test_cover_trigger_behavior_first( ) async def test_cover_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -260,7 +255,6 @@ async def test_cover_trigger_behavior_last( """Test cover trigger fires when the last cover changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/device_tracker/test_trigger.py b/tests/components/device_tracker/test_trigger.py index e7750443fbb..caa53e2fa27 100644 --- a/tests/components/device_tracker/test_trigger.py +++ b/tests/components/device_tracker/test_trigger.py @@ -5,7 +5,7 @@ from typing import Any import pytest from homeassistant.const import STATE_HOME, STATE_NOT_HOME -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -60,7 +60,6 @@ async def test_device_tracker_triggers_gated_by_labs_flag( ) async def test_device_tracker_home_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_device_trackers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -72,7 +71,6 @@ async def test_device_tracker_home_trigger_behavior_any( """Test that the device_tracker home triggers when any device_tracker changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_device_trackers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -105,7 +103,6 @@ async def test_device_tracker_home_trigger_behavior_any( ) async def test_device_tracker_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_device_trackers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -117,7 +114,6 @@ async def test_device_tracker_state_trigger_behavior_first( """Test that the device_tracker home triggers when the first device_tracker changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_device_trackers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -150,7 +146,6 @@ async def test_device_tracker_state_trigger_behavior_first( ) async def test_device_tracker_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_device_trackers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -162,7 +157,6 @@ async def test_device_tracker_state_trigger_behavior_last( """Test that the device_tracker home triggers when the last device_tracker changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_device_trackers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/door/test_trigger.py b/tests/components/door/test_trigger.py index 9f95ac8bb70..94685211e9b 100644 --- a/tests/components/door/test_trigger.py +++ b/tests/components/door/test_trigger.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -77,7 +77,6 @@ async def test_door_triggers_gated_by_labs_flag( ) async def test_door_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -89,7 +88,6 @@ async def test_door_trigger_binary_sensor_behavior_any( """Test door trigger fires for binary_sensor entities with device_class door.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -147,7 +145,6 @@ async def test_door_trigger_binary_sensor_behavior_any( ) async def test_door_trigger_cover_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -159,7 +156,6 @@ async def test_door_trigger_cover_behavior_any( """Test door trigger fires for cover entities with device_class door.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_door_trigger_cover_behavior_any( ) async def test_door_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_door_trigger_binary_sensor_behavior_first( """Test door trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -253,7 +247,6 @@ async def test_door_trigger_binary_sensor_behavior_first( ) async def test_door_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -265,7 +258,6 @@ async def test_door_trigger_binary_sensor_behavior_last( """Test door trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -323,7 +315,6 @@ async def test_door_trigger_binary_sensor_behavior_last( ) async def test_door_trigger_cover_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -335,7 +326,6 @@ async def test_door_trigger_cover_behavior_first( """Test door trigger fires on the first cover state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -393,7 +383,6 @@ async def test_door_trigger_cover_behavior_first( ) async def test_door_trigger_cover_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -405,7 +394,6 @@ async def test_door_trigger_cover_behavior_last( """Test door trigger fires when the last cover changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/event/test_trigger.py b/tests/components/event/test_trigger.py index 98800e16a26..86328949b33 100644 --- a/tests/components/event/test_trigger.py +++ b/tests/components/event/test_trigger.py @@ -3,8 +3,8 @@ import pytest from homeassistant.components.event.const import ATTR_EVENT_TYPE -from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -251,7 +251,6 @@ async def test_event_triggers_gated_by_labs_flag( ) async def test_event_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_events: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -261,6 +260,7 @@ async def test_event_state_trigger( states: list[TriggerStateDescription], ) -> None: """Test that the event trigger fires when an event entity receives a matching event.""" + calls: list[str] = [] other_entity_ids = set(target_events["included_entities"]) - {entity_id} # Set all events to the initial state @@ -268,20 +268,20 @@ async def test_event_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, trigger_options, trigger_target_config) + await arm_trigger(hass, trigger, trigger_options, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other events also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() diff --git a/tests/components/fan/test_trigger.py b/tests/components/fan/test_trigger.py index a56b0f62c83..0434f1f0787 100644 --- a/tests/components/fan/test_trigger.py +++ b/tests/components/fan/test_trigger.py @@ -5,7 +5,7 @@ from typing import Any import pytest from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -61,7 +61,6 @@ async def test_fan_triggers_gated_by_labs_flag( ) async def test_fan_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_fans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -73,7 +72,6 @@ async def test_fan_state_trigger_behavior_any( """Test that the fan state trigger fires when any fan state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_fans, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -106,7 +104,6 @@ async def test_fan_state_trigger_behavior_any( ) async def test_fan_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_fans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -118,7 +115,6 @@ async def test_fan_state_trigger_behavior_first( """Test that the fan state trigger fires when the first fan changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_fans, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -151,7 +147,6 @@ async def test_fan_state_trigger_behavior_first( ) async def test_fan_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_fans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -163,7 +158,6 @@ async def test_fan_state_trigger_behavior_last( """Test that the fan state trigger fires when the last fan changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_fans, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/garage_door/test_trigger.py b/tests/components/garage_door/test_trigger.py index 2ebaacae017..a1ac9deac96 100644 --- a/tests/components/garage_door/test_trigger.py +++ b/tests/components/garage_door/test_trigger.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -77,7 +77,6 @@ async def test_garage_door_triggers_gated_by_labs_flag( ) async def test_garage_door_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -89,7 +88,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_any( """Test garage door trigger fires for binary_sensor entities with device_class garage_door.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -147,7 +145,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_any( ) async def test_garage_door_trigger_cover_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -159,7 +156,6 @@ async def test_garage_door_trigger_cover_behavior_any( """Test garage door trigger fires for cover entities with device_class garage.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_garage_door_trigger_cover_behavior_any( ) async def test_garage_door_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_first( """Test garage door trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -253,7 +247,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_first( ) async def test_garage_door_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -265,7 +258,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_last( """Test garage door trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -323,7 +315,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_last( ) async def test_garage_door_trigger_cover_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -335,7 +326,6 @@ async def test_garage_door_trigger_cover_behavior_first( """Test garage door trigger fires on the first cover state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -393,7 +383,6 @@ async def test_garage_door_trigger_cover_behavior_first( ) async def test_garage_door_trigger_cover_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -405,7 +394,6 @@ async def test_garage_door_trigger_cover_behavior_last( """Test garage door trigger fires when the last cover changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/gate/test_trigger.py b/tests/components/gate/test_trigger.py index 8658f4d7243..e698ee1b414 100644 --- a/tests/components/gate/test_trigger.py +++ b/tests/components/gate/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState from homeassistant.const import ATTR_DEVICE_CLASS -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -87,7 +87,6 @@ async def test_gate_triggers_gated_by_labs_flag( ) async def test_gate_trigger_cover_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -99,7 +98,6 @@ async def test_gate_trigger_cover_behavior_any( """Test gate trigger fires for cover entities with device_class gate.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -157,7 +155,6 @@ async def test_gate_trigger_cover_behavior_any( ) async def test_gate_trigger_cover_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -169,7 +166,6 @@ async def test_gate_trigger_cover_behavior_first( """Test gate trigger fires on the first cover state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -227,7 +223,6 @@ async def test_gate_trigger_cover_behavior_first( ) async def test_gate_trigger_cover_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -239,7 +234,6 @@ async def test_gate_trigger_cover_behavior_last( """Test gate trigger fires when the last cover changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/humidifier/test_trigger.py b/tests/components/humidifier/test_trigger.py index 436c85c896d..fde5ed83a63 100644 --- a/tests/components/humidifier/test_trigger.py +++ b/tests/components/humidifier/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.humidifier.const import ATTR_ACTION, HumidifierAction from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -64,7 +64,6 @@ async def test_humidifier_triggers_gated_by_labs_flag( ) async def test_humidifier_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -76,7 +75,6 @@ async def test_humidifier_state_trigger_behavior_any( """Test that the humidifier state trigger fires when any humidifier state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -109,7 +107,6 @@ async def test_humidifier_state_trigger_behavior_any( ) async def test_humidifier_state_attribute_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -121,7 +118,6 @@ async def test_humidifier_state_attribute_trigger_behavior_any( """Test that the humidifier state trigger fires when any humidifier state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -154,7 +150,6 @@ async def test_humidifier_state_attribute_trigger_behavior_any( ) async def test_humidifier_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -166,7 +161,6 @@ async def test_humidifier_state_trigger_behavior_first( """Test that the humidifier state trigger fires when the first humidifier changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -199,7 +193,6 @@ async def test_humidifier_state_trigger_behavior_first( ) async def test_humidifier_state_attribute_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -211,7 +204,6 @@ async def test_humidifier_state_attribute_trigger_behavior_first( """Test that the humidifier state trigger fires when the first humidifier state changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -244,7 +236,6 @@ async def test_humidifier_state_attribute_trigger_behavior_first( ) async def test_humidifier_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -256,7 +247,6 @@ async def test_humidifier_state_trigger_behavior_last( """Test that the humidifier state trigger fires when the last humidifier changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -289,7 +279,6 @@ async def test_humidifier_state_trigger_behavior_last( ) async def test_humidifier_state_attribute_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -301,7 +290,6 @@ async def test_humidifier_state_attribute_trigger_behavior_last( """Test that the humidifier state trigger fires when the last humidifier state changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/humidity/test_trigger.py b/tests/components/humidity/test_trigger.py index 8a9dd3df18e..bc89a1c490f 100644 --- a/tests/components/humidity/test_trigger.py +++ b/tests/components/humidity/test_trigger.py @@ -14,7 +14,7 @@ from homeassistant.components.humidifier import ( from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.weather import ATTR_WEATHER_HUMIDITY from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -95,7 +95,6 @@ async def test_humidity_triggers_gated_by_labs_flag( ) async def test_humidity_trigger_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -107,7 +106,6 @@ async def test_humidity_trigger_sensor_behavior_any( """Test humidity trigger fires for sensor entities with device_class humidity.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -135,7 +133,6 @@ async def test_humidity_trigger_sensor_behavior_any( ) async def test_humidity_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, @@ -147,7 +144,6 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_first( """Test humidity 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, @@ -175,7 +171,6 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_first( ) async def test_humidity_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, @@ -187,7 +182,6 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_last( """Test humidity 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, @@ -221,7 +215,6 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_last( ) async def test_humidity_trigger_climate_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -233,7 +226,6 @@ async def test_humidity_trigger_climate_behavior_any( """Test humidity trigger fires for climate entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -261,7 +253,6 @@ async def test_humidity_trigger_climate_behavior_any( ) async def test_humidity_trigger_climate_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -273,7 +264,6 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_first( """Test humidity crossed_threshold trigger fires on the first climate state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -301,7 +291,6 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_first( ) async def test_humidity_trigger_climate_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -313,7 +302,6 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_last( """Test humidity crossed_threshold trigger fires when the last climate changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -347,7 +335,6 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_last( ) async def test_humidity_trigger_humidifier_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -359,7 +346,6 @@ async def test_humidity_trigger_humidifier_behavior_any( """Test humidity trigger fires for humidifier entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -387,7 +373,6 @@ async def test_humidity_trigger_humidifier_behavior_any( ) async def test_humidity_trigger_humidifier_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -399,7 +384,6 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_first( """Test humidity crossed_threshold trigger fires on the first humidifier state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -427,7 +411,6 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_first( ) async def test_humidity_trigger_humidifier_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -439,7 +422,6 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_last( """Test humidity crossed_threshold trigger fires when the last humidifier changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -473,7 +455,6 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_last( ) async def test_humidity_trigger_weather_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -485,7 +466,6 @@ async def test_humidity_trigger_weather_behavior_any( """Test humidity trigger fires for weather entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -513,7 +493,6 @@ async def test_humidity_trigger_weather_behavior_any( ) async def test_humidity_trigger_weather_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -525,7 +504,6 @@ async def test_humidity_trigger_weather_crossed_threshold_behavior_first( """Test humidity crossed_threshold trigger fires on the first weather state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -553,7 +531,6 @@ async def test_humidity_trigger_weather_crossed_threshold_behavior_first( ) async def test_humidity_trigger_weather_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -565,7 +542,6 @@ async def test_humidity_trigger_weather_crossed_threshold_behavior_last( """Test humidity crossed_threshold trigger fires when the last weather changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -606,7 +582,6 @@ async def test_humidity_trigger_weather_crossed_threshold_behavior_last( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_humidity_trigger_ignores_limit_entity_with_wrong_unit( hass: HomeAssistant, - service_calls: list[ServiceCall], trigger: str, trigger_options: dict[str, Any], limit_entities: list[str], @@ -614,7 +589,6 @@ async def test_humidity_trigger_ignores_limit_entity_with_wrong_unit( """Test humidity triggers do not fire if limit entity unit is not %.""" await assert_trigger_ignores_limit_entities_with_wrong_unit( hass, - service_calls=service_calls, trigger=trigger, trigger_options=trigger_options, entity_id="climate.test_climate", diff --git a/tests/components/illuminance/test_trigger.py b/tests/components/illuminance/test_trigger.py index e9309b9d8d9..a53b218d5b7 100644 --- a/tests/components/illuminance/test_trigger.py +++ b/tests/components/illuminance/test_trigger.py @@ -14,7 +14,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -97,7 +97,6 @@ async def test_illuminance_triggers_gated_by_labs_flag( ) async def test_illuminance_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -109,7 +108,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_any( """Test illuminance trigger fires for binary_sensor entities with device_class light.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -150,7 +148,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_any( ) async def test_illuminance_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -162,7 +159,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_first( """Test illuminance trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -203,7 +199,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_first( ) async def test_illuminance_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -215,7 +210,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_last( """Test illuminance trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -251,7 +245,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_last( ) async def test_illuminance_trigger_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -263,7 +256,6 @@ async def test_illuminance_trigger_sensor_behavior_any( """Test illuminance trigger fires for sensor entities with device_class illuminance.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -291,7 +283,6 @@ async def test_illuminance_trigger_sensor_behavior_any( ) async def test_illuminance_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, @@ -303,7 +294,6 @@ async def test_illuminance_trigger_sensor_crossed_threshold_behavior_first( """Test illuminance 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, @@ -331,7 +321,6 @@ async def test_illuminance_trigger_sensor_crossed_threshold_behavior_first( ) async def test_illuminance_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, @@ -343,7 +332,6 @@ async def test_illuminance_trigger_sensor_crossed_threshold_behavior_last( """Test illuminance 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, @@ -379,7 +367,6 @@ async def test_illuminance_trigger_sensor_crossed_threshold_behavior_last( ) async def test_illuminance_trigger_number_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_numbers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -391,7 +378,6 @@ async def test_illuminance_trigger_number_behavior_any( """Test illuminance trigger fires for number entities with device_class illuminance.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_numbers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -419,7 +405,6 @@ async def test_illuminance_trigger_number_behavior_any( ) async def test_illuminance_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, @@ -431,7 +416,6 @@ async def test_illuminance_trigger_number_crossed_threshold_behavior_first( """Test illuminance 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, @@ -459,7 +443,6 @@ async def test_illuminance_trigger_number_crossed_threshold_behavior_first( ) async def test_illuminance_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, @@ -471,7 +454,6 @@ async def test_illuminance_trigger_number_crossed_threshold_behavior_last( """Test illuminance 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, diff --git a/tests/components/lawn_mower/test_trigger.py b/tests/components/lawn_mower/test_trigger.py index 9d031993b3c..41e57307b17 100644 --- a/tests/components/lawn_mower/test_trigger.py +++ b/tests/components/lawn_mower/test_trigger.py @@ -5,7 +5,7 @@ from typing import Any import pytest from homeassistant.components.lawn_mower import LawnMowerActivity -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -80,7 +80,6 @@ async def test_lawn_mower_triggers_gated_by_labs_flag( ) async def test_lawn_mower_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lawn_mowers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -92,7 +91,6 @@ async def test_lawn_mower_state_trigger_behavior_any( """Test that the lawn mower state trigger fires when any lawn mower state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_lawn_mowers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -140,7 +138,6 @@ async def test_lawn_mower_state_trigger_behavior_any( ) async def test_lawn_mower_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lawn_mowers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -152,7 +149,6 @@ async def test_lawn_mower_state_trigger_behavior_first( """Test that the lawn mower state trigger fires when the first lawn mower changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_lawn_mowers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_lawn_mower_state_trigger_behavior_first( ) async def test_lawn_mower_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lawn_mowers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_lawn_mower_state_trigger_behavior_last( """Test that the lawn_mower state trigger fires when the last lawn_mower changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_lawn_mowers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/light/test_trigger.py b/tests/components/light/test_trigger.py index 3e235637ce5..b80a8d13e1b 100644 --- a/tests/components/light/test_trigger.py +++ b/tests/components/light/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -188,7 +188,6 @@ async def test_light_triggers_gated_by_labs_flag( ) async def test_light_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -200,7 +199,6 @@ async def test_light_state_trigger_behavior_any( """Test that the light state trigger fires when any light state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -229,7 +227,6 @@ async def test_light_state_trigger_behavior_any( ) async def test_light_state_attribute_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -241,7 +238,6 @@ async def test_light_state_attribute_trigger_behavior_any( """Test that the light state trigger fires when any light state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -274,7 +270,6 @@ async def test_light_state_attribute_trigger_behavior_any( ) async def test_light_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -286,7 +281,6 @@ async def test_light_state_trigger_behavior_first( """Test that the light state trigger fires when the first light changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -312,7 +306,6 @@ async def test_light_state_trigger_behavior_first( ) async def test_light_state_attribute_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -324,7 +317,6 @@ async def test_light_state_attribute_trigger_behavior_first( """Test that the light state trigger fires when the first light state changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -357,7 +349,6 @@ async def test_light_state_attribute_trigger_behavior_first( ) async def test_light_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -369,7 +360,6 @@ async def test_light_state_trigger_behavior_last( """Test that the light state trigger fires when the last light changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -395,7 +385,6 @@ async def test_light_state_trigger_behavior_last( ) async def test_light_state_attribute_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -407,7 +396,6 @@ async def test_light_state_attribute_trigger_behavior_last( """Test that the light state trigger fires when the last light state changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -448,7 +436,6 @@ async def test_light_state_attribute_trigger_behavior_last( ) async def test_light_trigger_ignores_limit_entity_with_wrong_unit( hass: HomeAssistant, - service_calls: list[ServiceCall], trigger: str, trigger_options: dict[str, Any], limit_entities: list[str], @@ -456,7 +443,6 @@ async def test_light_trigger_ignores_limit_entity_with_wrong_unit( """Test numerical triggers do not fire if limit entities have the wrong unit.""" await assert_trigger_ignores_limit_entities_with_wrong_unit( hass, - service_calls=service_calls, trigger=trigger, trigger_options=trigger_options, entity_id="light.test_light", diff --git a/tests/components/lock/test_trigger.py b/tests/components/lock/test_trigger.py index 7e51f72cf44..b281736030e 100644 --- a/tests/components/lock/test_trigger.py +++ b/tests/components/lock/test_trigger.py @@ -5,7 +5,7 @@ from typing import Any import pytest from homeassistant.components.lock import DOMAIN, LockState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -74,7 +74,6 @@ async def test_lock_triggers_gated_by_labs_flag( ) async def test_lock_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_locks: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -86,7 +85,6 @@ async def test_lock_state_trigger_behavior_any( """Test that the lock state trigger fires when any lock state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_locks, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -129,7 +127,6 @@ async def test_lock_state_trigger_behavior_any( ) async def test_lock_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_locks: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -141,7 +138,6 @@ async def test_lock_state_trigger_behavior_first( """Test that the lock state trigger fires when the first lock changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_locks, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -184,7 +180,6 @@ async def test_lock_state_trigger_behavior_first( ) async def test_lock_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_locks: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -196,7 +191,6 @@ async def test_lock_state_trigger_behavior_last( """Test that the lock state trigger fires when the last lock changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_locks, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/media_player/test_trigger.py b/tests/components/media_player/test_trigger.py index 62d83bd9186..84267568423 100644 --- a/tests/components/media_player/test_trigger.py +++ b/tests/components/media_player/test_trigger.py @@ -5,7 +5,7 @@ from typing import Any import pytest from homeassistant.components.media_player import MediaPlayerState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -63,7 +63,6 @@ async def test_media_player_triggers_gated_by_labs_flag( ) async def test_media_player_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_media_players: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -75,7 +74,6 @@ async def test_media_player_state_trigger_behavior_any( """Test that the media player state trigger fires when any media player state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_media_players, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -111,7 +109,6 @@ async def test_media_player_state_trigger_behavior_any( ) async def test_media_player_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_media_players: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -123,7 +120,6 @@ async def test_media_player_state_trigger_behavior_first( """Test that the media player state trigger fires when the first media player changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_media_players, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -159,7 +155,6 @@ async def test_media_player_state_trigger_behavior_first( ) async def test_media_player_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_media_players: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -171,7 +166,6 @@ async def test_media_player_state_trigger_behavior_last( """Test that the media player state trigger fires when the last media player changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_media_players, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/moisture/test_trigger.py b/tests/components/moisture/test_trigger.py index 42393ac8685..4137a661aa3 100644 --- a/tests/components/moisture/test_trigger.py +++ b/tests/components/moisture/test_trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -94,7 +94,6 @@ async def test_moisture_triggers_gated_by_labs_flag( ) async def test_moisture_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -106,7 +105,6 @@ async def test_moisture_trigger_binary_sensor_behavior_any( """Test moisture trigger fires for binary_sensor entities with device_class moisture.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -147,7 +145,6 @@ async def test_moisture_trigger_binary_sensor_behavior_any( ) async def test_moisture_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -159,7 +156,6 @@ async def test_moisture_trigger_binary_sensor_behavior_first( """Test moisture trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_moisture_trigger_binary_sensor_behavior_first( ) async def test_moisture_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_moisture_trigger_binary_sensor_behavior_last( """Test moisture trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -245,7 +239,6 @@ async def test_moisture_trigger_binary_sensor_behavior_last( ) async def test_moisture_trigger_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -257,7 +250,6 @@ async def test_moisture_trigger_sensor_behavior_any( """Test moisture trigger fires for sensor entities with device_class moisture.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -285,7 +277,6 @@ async def test_moisture_trigger_sensor_behavior_any( ) async def test_moisture_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, @@ -297,7 +288,6 @@ async def test_moisture_trigger_sensor_crossed_threshold_behavior_first( """Test moisture 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, @@ -325,7 +315,6 @@ async def test_moisture_trigger_sensor_crossed_threshold_behavior_first( ) async def test_moisture_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, @@ -337,7 +326,6 @@ async def test_moisture_trigger_sensor_crossed_threshold_behavior_last( """Test moisture 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, @@ -373,7 +361,6 @@ async def test_moisture_trigger_sensor_crossed_threshold_behavior_last( ) async def test_moisture_trigger_number_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_numbers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -385,7 +372,6 @@ async def test_moisture_trigger_number_behavior_any( """Test moisture trigger fires for number entities with device_class moisture.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_numbers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -413,7 +399,6 @@ async def test_moisture_trigger_number_behavior_any( ) async def test_moisture_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, @@ -425,7 +410,6 @@ async def test_moisture_trigger_number_crossed_threshold_behavior_first( """Test moisture 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, @@ -453,7 +437,6 @@ async def test_moisture_trigger_number_crossed_threshold_behavior_first( ) async def test_moisture_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, @@ -465,7 +448,6 @@ async def test_moisture_trigger_number_crossed_threshold_behavior_last( """Test moisture 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, @@ -506,7 +488,6 @@ async def test_moisture_trigger_number_crossed_threshold_behavior_last( ) async def test_moisture_trigger_ignores_limit_entity_with_wrong_unit( hass: HomeAssistant, - service_calls: list[ServiceCall], trigger: str, trigger_options: dict[str, Any], limit_entities: list[str], @@ -518,7 +499,6 @@ async def test_moisture_trigger_ignores_limit_entity_with_wrong_unit( } await assert_trigger_ignores_limit_entities_with_wrong_unit( hass, - service_calls=service_calls, trigger=trigger, trigger_options=trigger_options, entity_id="sensor.test_moisture", diff --git a/tests/components/motion/test_trigger.py b/tests/components/motion/test_trigger.py index e46a93958c7..9185f581e7d 100644 --- a/tests/components/motion/test_trigger.py +++ b/tests/components/motion/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -70,7 +70,6 @@ async def test_motion_triggers_gated_by_labs_flag( ) async def test_motion_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -82,7 +81,6 @@ async def test_motion_trigger_binary_sensor_behavior_any( """Test motion trigger fires for binary_sensor entities with device_class motion.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -123,7 +121,6 @@ async def test_motion_trigger_binary_sensor_behavior_any( ) async def test_motion_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -135,7 +132,6 @@ async def test_motion_trigger_binary_sensor_behavior_first( """Test motion trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -176,7 +172,6 @@ async def test_motion_trigger_binary_sensor_behavior_first( ) async def test_motion_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -188,7 +183,6 @@ async def test_motion_trigger_binary_sensor_behavior_last( """Test motion trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/occupancy/test_trigger.py b/tests/components/occupancy/test_trigger.py index 641b9052e4e..3c53e1fcfc0 100644 --- a/tests/components/occupancy/test_trigger.py +++ b/tests/components/occupancy/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -70,7 +70,6 @@ async def test_occupancy_triggers_gated_by_labs_flag( ) async def test_occupancy_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -82,7 +81,6 @@ async def test_occupancy_trigger_binary_sensor_behavior_any( """Test occupancy trigger fires for binary_sensor entities with device_class occupancy.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -123,7 +121,6 @@ async def test_occupancy_trigger_binary_sensor_behavior_any( ) async def test_occupancy_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -135,7 +132,6 @@ async def test_occupancy_trigger_binary_sensor_behavior_first( """Test occupancy trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -176,7 +172,6 @@ async def test_occupancy_trigger_binary_sensor_behavior_first( ) async def test_occupancy_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -188,7 +183,6 @@ async def test_occupancy_trigger_binary_sensor_behavior_last( """Test occupancy trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/person/test_trigger.py b/tests/components/person/test_trigger.py index 72e92139074..a9ca192f801 100644 --- a/tests/components/person/test_trigger.py +++ b/tests/components/person/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.person.const import DOMAIN from homeassistant.const import STATE_HOME, STATE_NOT_HOME -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -61,7 +61,6 @@ async def test_person_triggers_gated_by_labs_flag( ) async def test_person_home_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_persons: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -73,7 +72,6 @@ async def test_person_home_trigger_behavior_any( """Test that the person home triggers when any person changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_persons, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -106,7 +104,6 @@ async def test_person_home_trigger_behavior_any( ) async def test_person_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_persons: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -118,7 +115,6 @@ async def test_person_state_trigger_behavior_first( """Test that the person home triggers when the first person changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_persons, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -151,7 +147,6 @@ async def test_person_state_trigger_behavior_first( ) async def test_person_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_persons: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -163,7 +158,6 @@ async def test_person_state_trigger_behavior_last( """Test that the person home triggers when the last person changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_persons, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/power/test_trigger.py b/tests/components/power/test_trigger.py index be902c46873..a7599dca96c 100644 --- a/tests/components/power/test_trigger.py +++ b/tests/components/power/test_trigger.py @@ -7,7 +7,7 @@ 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 homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -74,7 +74,6 @@ async def test_power_triggers_gated_by_labs_flag( ) 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, @@ -86,7 +85,6 @@ async def test_power_trigger_sensor_behavior_any( """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, @@ -115,7 +113,6 @@ async def test_power_trigger_sensor_behavior_any( ) 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, @@ -127,7 +124,6 @@ async def test_power_trigger_sensor_crossed_threshold_behavior_first( """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, @@ -156,7 +152,6 @@ async def test_power_trigger_sensor_crossed_threshold_behavior_first( ) 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, @@ -168,7 +163,6 @@ async def test_power_trigger_sensor_crossed_threshold_behavior_last( """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, @@ -206,7 +200,6 @@ async def test_power_trigger_sensor_crossed_threshold_behavior_last( ) 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, @@ -218,7 +211,6 @@ async def test_power_trigger_number_behavior_any( """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, @@ -247,7 +239,6 @@ async def test_power_trigger_number_behavior_any( ) 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, @@ -259,7 +250,6 @@ async def test_power_trigger_number_crossed_threshold_behavior_first( """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, @@ -288,7 +278,6 @@ async def test_power_trigger_number_crossed_threshold_behavior_first( ) 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, @@ -300,7 +289,6 @@ async def test_power_trigger_number_crossed_threshold_behavior_last( """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, diff --git a/tests/components/remote/test_trigger.py b/tests/components/remote/test_trigger.py index bc8654bab48..84d67e2f949 100644 --- a/tests/components/remote/test_trigger.py +++ b/tests/components/remote/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.remote import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -59,7 +59,6 @@ async def test_remote_triggers_gated_by_labs_flag( ) async def test_remote_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_remotes: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -71,7 +70,6 @@ async def test_remote_state_trigger_behavior_any( """Test that the remote triggers when any remote changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_remotes, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -104,7 +102,6 @@ async def test_remote_state_trigger_behavior_any( ) async def test_remote_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_remotes: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -116,7 +113,6 @@ async def test_remote_state_trigger_behavior_first( """Test that the remote triggers when the first remote changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_remotes, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -149,7 +145,6 @@ async def test_remote_state_trigger_behavior_first( ) async def test_remote_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_remotes: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -161,7 +156,6 @@ async def test_remote_state_trigger_behavior_last( """Test that the remote triggers when the last remote changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_remotes, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/scene/test_trigger.py b/tests/components/scene/test_trigger.py index cca28a1d120..6b4621b709a 100644 --- a/tests/components/scene/test_trigger.py +++ b/tests/components/scene/test_trigger.py @@ -2,8 +2,8 @@ import pytest -from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -147,7 +147,6 @@ async def test_scene_triggers_gated_by_labs_flag( ) async def test_scene_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_scenes: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -156,6 +155,7 @@ async def test_scene_state_trigger( states: list[TriggerStateDescription], ) -> None: """Test that the scene state trigger fires when targeted scene state changes.""" + calls: list[str] = [] other_entity_ids = set(target_scenes["included_entities"]) - {entity_id} # Set all scenes, including the tested scene, to the initial state @@ -163,20 +163,20 @@ async def test_scene_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other scenes also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() diff --git a/tests/components/schedule/test_trigger.py b/tests/components/schedule/test_trigger.py index 4a4f1553839..a5882fc2d38 100644 --- a/tests/components/schedule/test_trigger.py +++ b/tests/components/schedule/test_trigger.py @@ -13,14 +13,8 @@ from homeassistant.components.schedule.const import ( CONF_TO, DOMAIN, ) -from homeassistant.const import ( - CONF_ENTITY_ID, - CONF_ICON, - CONF_NAME, - STATE_OFF, - STATE_ON, -) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import CONF_ICON, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant from tests.common import async_fire_time_changed from tests.components.common import ( @@ -78,7 +72,6 @@ async def test_schedule_triggers_gated_by_labs_flag( ) async def test_schedule_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_schedules: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -90,7 +83,6 @@ async def test_schedule_state_trigger_behavior_any( """Test that the schedule state trigger fires when any schedule state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_schedules, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -123,7 +115,6 @@ async def test_schedule_state_trigger_behavior_any( ) async def test_schedule_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_schedules: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -135,7 +126,6 @@ async def test_schedule_state_trigger_behavior_first( """Test that the schedule state trigger fires when the first schedule changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_schedules, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -168,7 +158,6 @@ async def test_schedule_state_trigger_behavior_first( ) async def test_schedule_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_schedules: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -180,7 +169,6 @@ async def test_schedule_state_trigger_behavior_last( """Test that the schedule state trigger fires when the last schedule changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_schedules, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -194,11 +182,11 @@ async def test_schedule_state_trigger_behavior_last( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_schedule_state_trigger_back_to_back( hass: HomeAssistant, - service_calls: list[ServiceCall], schedule_setup: Callable[..., Coroutine[Any, Any, bool]], freezer: FrozenDateTimeFactory, ) -> None: """Test that the schedule state trigger fires when transitioning between two back-to-back schedule blocks.""" + calls: list[str] = [] freezer.move_to("2022-08-30 13:20:00-07:00") entity_id = "schedule.from_yaml" @@ -223,6 +211,7 @@ async def test_schedule_state_trigger_back_to_back( "schedule.turned_on", {}, {"entity_id": [entity_id]}, + calls, ) # initial state @@ -241,9 +230,9 @@ async def test_schedule_state_trigger_back_to_back( assert state.state == STATE_ON assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T22:30:00-07:00" - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id + calls.clear() # move time into second block (back-to-back) freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) @@ -255,9 +244,9 @@ async def test_schedule_state_trigger_back_to_back( assert state.state == STATE_ON assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T23:00:00-07:00" - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id + calls.clear() # move time to after second block to ensure it turns off freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) @@ -269,4 +258,4 @@ async def test_schedule_state_trigger_back_to_back( assert state.state == STATE_OFF assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-11T22:00:00-07:00" - assert len(service_calls) == 0 + assert len(calls) == 0 diff --git a/tests/components/select/test_trigger.py b/tests/components/select/test_trigger.py index 89319000b9e..081b61edaeb 100644 --- a/tests/components/select/test_trigger.py +++ b/tests/components/select/test_trigger.py @@ -3,7 +3,7 @@ import pytest from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -93,7 +93,6 @@ STATE_SEQUENCE = [ @pytest.mark.parametrize(("trigger", "states"), STATE_SEQUENCE) async def test_select_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_selects: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -104,7 +103,6 @@ async def test_select_state_trigger( """Test that the select trigger fires when targeted select state changes.""" await _assert_select_trigger_fires( hass, - service_calls=service_calls, target_entities=target_selects, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -122,7 +120,6 @@ async def test_select_state_trigger( @pytest.mark.parametrize(("trigger", "states"), STATE_SEQUENCE) async def test_input_select_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_input_selects: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -133,7 +130,6 @@ async def test_input_select_state_trigger( """Test that the select trigger fires when targeted input_select state changes.""" await _assert_select_trigger_fires( hass, - service_calls=service_calls, target_entities=target_input_selects, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -145,7 +141,6 @@ async def test_input_select_state_trigger( async def _assert_select_trigger_fires( hass: HomeAssistant, - service_calls: list[ServiceCall], target_entities: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -154,7 +149,7 @@ async def _assert_select_trigger_fires( states: list[TriggerStateDescription], ) -> None: """Test that the select trigger fires when targeted state changes.""" - + calls: list[str] = [] other_entity_ids = set(target_entities["included_entities"]) - {entity_id} # Set all entities to the initial state @@ -162,23 +157,23 @@ async def _assert_select_trigger_fires( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other targeted entities also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() # --- Cross-domain test --- @@ -187,9 +182,9 @@ async def _assert_select_trigger_fires( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_select_trigger_fires_for_both_domains( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test that the select trigger fires for both select and input_select entities.""" + calls: list[str] = [] entity_id_select = "select.test_select" entity_id_input_select = "input_select.test_input_select" @@ -202,18 +197,19 @@ async def test_select_trigger_fires_for_both_domains( "select.selection_changed", None, {CONF_ENTITY_ID: [entity_id_select, entity_id_input_select]}, + calls, ) # select entity changes - should trigger hass.states.async_set(entity_id_select, "option_b") await hass.async_block_till_done() - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_select - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id_select + calls.clear() # input_select entity changes - should also trigger hass.states.async_set(entity_id_input_select, "option_b") await hass.async_block_till_done() - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_input_select - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id_input_select + calls.clear() diff --git a/tests/components/siren/test_trigger.py b/tests/components/siren/test_trigger.py index ff33894ddc9..46d97d6379c 100644 --- a/tests/components/siren/test_trigger.py +++ b/tests/components/siren/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.siren import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -62,7 +62,6 @@ async def test_siren_triggers_gated_by_labs_flag( ) async def test_siren_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sirens: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -74,7 +73,6 @@ async def test_siren_state_trigger_behavior_any( """Test that the siren state trigger fires when any siren state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sirens, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -107,7 +105,6 @@ async def test_siren_state_trigger_behavior_any( ) async def test_siren_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sirens: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -119,7 +116,6 @@ async def test_siren_state_trigger_behavior_first( """Test that the siren state trigger fires when the first siren changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_sirens, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -152,7 +148,6 @@ async def test_siren_state_trigger_behavior_first( ) async def test_siren_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sirens: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -164,7 +159,6 @@ async def test_siren_state_trigger_behavior_last( """Test that the siren state trigger fires when the last siren changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_sirens, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/switch/test_trigger.py b/tests/components/switch/test_trigger.py index 47a1578c01f..8fe7a975297 100644 --- a/tests/components/switch/test_trigger.py +++ b/tests/components/switch/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.switch import DOMAIN from homeassistant.const import CONF_ENTITY_ID, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -74,7 +74,6 @@ async def test_switch_triggers_gated_by_labs_flag( ) async def test_switch_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_switches: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -86,7 +85,6 @@ async def test_switch_state_trigger_behavior_any( """Test that the switch state trigger fires when any switch state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_switches, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -108,7 +106,6 @@ async def test_switch_state_trigger_behavior_any( ) async def test_switch_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_switches: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -120,7 +117,6 @@ async def test_switch_state_trigger_behavior_first( """Test that the switch state trigger fires when the first switch changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_switches, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -142,7 +138,6 @@ async def test_switch_state_trigger_behavior_first( ) async def test_switch_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_switches: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -154,7 +149,6 @@ async def test_switch_state_trigger_behavior_last( """Test that the switch state trigger fires when the last switch changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_switches, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -179,7 +173,6 @@ async def test_switch_state_trigger_behavior_last( ) async def test_input_boolean_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_input_booleans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -191,7 +184,6 @@ async def test_input_boolean_state_trigger_behavior_any( """Test that the switch trigger fires when any input_boolean state changes.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_input_booleans, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -213,7 +205,6 @@ async def test_input_boolean_state_trigger_behavior_any( ) async def test_input_boolean_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_input_booleans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -225,7 +216,6 @@ async def test_input_boolean_state_trigger_behavior_first( """Test that the switch trigger fires when the first input_boolean changes.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_input_booleans, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -247,7 +237,6 @@ async def test_input_boolean_state_trigger_behavior_first( ) async def test_input_boolean_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_input_booleans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -259,7 +248,6 @@ async def test_input_boolean_state_trigger_behavior_last( """Test that the switch trigger fires when the last input_boolean changes.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_input_booleans, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -276,9 +264,9 @@ async def test_input_boolean_state_trigger_behavior_last( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_switch_trigger_fires_for_both_domains( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test that the switch trigger fires for both switch and input_boolean entities.""" + calls: list[str] = [] entity_id_switch = "switch.test_switch" entity_id_input_boolean = "input_boolean.test_input_boolean" @@ -291,18 +279,19 @@ async def test_switch_trigger_fires_for_both_domains( "switch.turned_on", {}, {CONF_ENTITY_ID: [entity_id_switch, entity_id_input_boolean]}, + calls, ) # switch entity changes - should trigger hass.states.async_set(entity_id_switch, STATE_ON) await hass.async_block_till_done() - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_switch - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id_switch + calls.clear() # input_boolean entity changes - should also trigger hass.states.async_set(entity_id_input_boolean, STATE_ON) await hass.async_block_till_done() - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_input_boolean - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id_input_boolean + calls.clear() diff --git a/tests/components/temperature/test_trigger.py b/tests/components/temperature/test_trigger.py index 6709867e698..29e565cda17 100644 --- a/tests/components/temperature/test_trigger.py +++ b/tests/components/temperature/test_trigger.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -112,7 +112,6 @@ async def test_temperature_triggers_gated_by_labs_flag( ) async def test_temperature_trigger_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -124,7 +123,6 @@ async def test_temperature_trigger_sensor_behavior_any( """Test temperature trigger fires for sensor entities with device_class temperature.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -153,7 +151,6 @@ async def test_temperature_trigger_sensor_behavior_any( ) async def test_temperature_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, @@ -165,7 +162,6 @@ async def test_temperature_trigger_sensor_crossed_threshold_behavior_first( """Test temperature 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, @@ -194,7 +190,6 @@ async def test_temperature_trigger_sensor_crossed_threshold_behavior_first( ) async def test_temperature_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, @@ -206,7 +201,6 @@ async def test_temperature_trigger_sensor_crossed_threshold_behavior_last( """Test temperature 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, @@ -244,7 +238,6 @@ async def test_temperature_trigger_sensor_crossed_threshold_behavior_last( ) async def test_temperature_trigger_climate_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -256,7 +249,6 @@ async def test_temperature_trigger_climate_behavior_any( """Test temperature trigger fires for climate entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -285,7 +277,6 @@ async def test_temperature_trigger_climate_behavior_any( ) async def test_temperature_trigger_climate_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -297,7 +288,6 @@ async def test_temperature_trigger_climate_crossed_threshold_behavior_first( """Test temperature crossed_threshold trigger fires on the first climate state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -326,7 +316,6 @@ async def test_temperature_trigger_climate_crossed_threshold_behavior_first( ) async def test_temperature_trigger_climate_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -338,7 +327,6 @@ async def test_temperature_trigger_climate_crossed_threshold_behavior_last( """Test temperature crossed_threshold trigger fires when the last climate changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -376,7 +364,6 @@ async def test_temperature_trigger_climate_crossed_threshold_behavior_last( ) async def test_temperature_trigger_water_heater_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -388,7 +375,6 @@ async def test_temperature_trigger_water_heater_behavior_any( """Test temperature trigger fires for water_heater entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -417,7 +403,6 @@ async def test_temperature_trigger_water_heater_behavior_any( ) async def test_temperature_trigger_water_heater_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -429,7 +414,6 @@ async def test_temperature_trigger_water_heater_crossed_threshold_behavior_first """Test temperature crossed_threshold trigger fires on the first water_heater state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -458,7 +442,6 @@ async def test_temperature_trigger_water_heater_crossed_threshold_behavior_first ) async def test_temperature_trigger_water_heater_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -470,7 +453,6 @@ async def test_temperature_trigger_water_heater_crossed_threshold_behavior_last( """Test temperature crossed_threshold trigger fires when the last water_heater changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -510,7 +492,6 @@ async def test_temperature_trigger_water_heater_crossed_threshold_behavior_last( ) async def test_temperature_trigger_weather_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -522,7 +503,6 @@ async def test_temperature_trigger_weather_behavior_any( """Test temperature trigger fires for weather entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -552,7 +532,6 @@ async def test_temperature_trigger_weather_behavior_any( ) async def test_temperature_trigger_weather_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -564,7 +543,6 @@ async def test_temperature_trigger_weather_crossed_threshold_behavior_first( """Test temperature crossed_threshold trigger fires on the first weather state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -594,7 +572,6 @@ async def test_temperature_trigger_weather_crossed_threshold_behavior_first( ) async def test_temperature_trigger_weather_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -606,7 +583,6 @@ async def test_temperature_trigger_weather_crossed_threshold_behavior_last( """Test temperature crossed_threshold trigger fires when the last weather changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -623,9 +599,9 @@ async def test_temperature_trigger_weather_crossed_threshold_behavior_last( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_temperature_trigger_unit_conversion_sensor_celsius_to_fahrenheit( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test temperature trigger converts sensor value from °C to °F for threshold comparison.""" + calls: list[str] = [] entity_id = "sensor.test_temp" # Sensor reports in °C, trigger configured in °F with threshold above 70°F @@ -649,6 +625,7 @@ async def test_temperature_trigger_unit_conversion_sensor_celsius_to_fahrenheit( } }, {CONF_ENTITY_ID: [entity_id]}, + calls, ) # 20°C = 68°F, which is below 70°F - should NOT trigger @@ -661,7 +638,7 @@ async def test_temperature_trigger_unit_conversion_sensor_celsius_to_fahrenheit( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # 22°C = 71.6°F, which is above 70°F - should trigger hass.states.async_set( @@ -673,16 +650,16 @@ async def test_temperature_trigger_unit_conversion_sensor_celsius_to_fahrenheit( }, ) await hass.async_block_till_done() - assert len(service_calls) == 1 - service_calls.clear() + assert len(calls) == 1 + calls.clear() @pytest.mark.usefixtures("enable_labs_preview_features") async def test_temperature_trigger_unit_conversion_sensor_fahrenheit_to_celsius( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test temperature trigger converts sensor value from °F to °C for threshold comparison.""" + calls: list[str] = [] entity_id = "sensor.test_temp" # Sensor reports in °F, trigger configured in °C with threshold above 25°C @@ -706,6 +683,7 @@ async def test_temperature_trigger_unit_conversion_sensor_fahrenheit_to_celsius( } }, {CONF_ENTITY_ID: [entity_id]}, + calls, ) # 70°F = 21.1°C, which is below 25°C - should NOT trigger @@ -718,7 +696,7 @@ async def test_temperature_trigger_unit_conversion_sensor_fahrenheit_to_celsius( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # 80°F = 26.7°C, which is above 25°C - should trigger hass.states.async_set( @@ -730,16 +708,16 @@ async def test_temperature_trigger_unit_conversion_sensor_fahrenheit_to_celsius( }, ) await hass.async_block_till_done() - assert len(service_calls) == 1 - service_calls.clear() + assert len(calls) == 1 + calls.clear() @pytest.mark.usefixtures("enable_labs_preview_features") async def test_temperature_trigger_unit_conversion_changed( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test temperature changed trigger with unit conversion and above/below limits.""" + calls: list[str] = [] entity_id = "sensor.test_temp" # Sensor reports in °C, trigger configured in °F: above 68°F (20°C), below 77°F (25°C) @@ -764,6 +742,7 @@ async def test_temperature_trigger_unit_conversion_changed( } }, {CONF_ENTITY_ID: [entity_id]}, + calls, ) # 18°C = 64.4°F, below 68°F - should NOT trigger @@ -776,7 +755,7 @@ async def test_temperature_trigger_unit_conversion_changed( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # 22°C = 71.6°F, between 68°F and 77°F - should trigger hass.states.async_set( @@ -788,8 +767,8 @@ async def test_temperature_trigger_unit_conversion_changed( }, ) await hass.async_block_till_done() - assert len(service_calls) == 1 - service_calls.clear() + assert len(calls) == 1 + calls.clear() # 26°C = 78.8°F, above 77°F - should NOT trigger hass.states.async_set( @@ -801,15 +780,15 @@ async def test_temperature_trigger_unit_conversion_changed( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 @pytest.mark.usefixtures("enable_labs_preview_features") async def test_temperature_trigger_unit_conversion_weather( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test temperature trigger with unit conversion for weather entities.""" + calls: list[str] = [] entity_id = "weather.test" # Weather reports temperature in °F, trigger configured in °C with threshold above 25°C @@ -833,6 +812,7 @@ async def test_temperature_trigger_unit_conversion_weather( } }, {CONF_ENTITY_ID: [entity_id]}, + calls, ) # 70°F = 21.1°C, below 25°C - should NOT trigger @@ -845,7 +825,7 @@ async def test_temperature_trigger_unit_conversion_weather( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # 80°F = 26.7°C, above 25°C - should trigger hass.states.async_set( @@ -857,5 +837,5 @@ async def test_temperature_trigger_unit_conversion_weather( }, ) await hass.async_block_till_done() - assert len(service_calls) == 1 - service_calls.clear() + assert len(calls) == 1 + calls.clear() diff --git a/tests/components/text/test_trigger.py b/tests/components/text/test_trigger.py index 1a76eaed44f..38f5ed85f5f 100644 --- a/tests/components/text/test_trigger.py +++ b/tests/components/text/test_trigger.py @@ -4,8 +4,8 @@ import pytest from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN from homeassistant.components.text.const import DOMAIN -from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from tests.components.common import ( BasicTriggerStateDescription, @@ -142,7 +142,6 @@ async def test_text_triggers_gated_by_labs_flag( @pytest.mark.parametrize(("trigger", "states"), TEST_TRIGGER_STATES) async def test_text_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_texts: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -151,6 +150,7 @@ async def test_text_state_trigger( states: list[BasicTriggerStateDescription], ) -> None: """Test that the text state trigger fires when targeted text state changes.""" + calls: list[str] = [] other_entity_ids = set(target_texts["included_entities"]) - {entity_id} # Set all texts, including the tested text, to the initial state @@ -158,23 +158,23 @@ async def test_text_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other texts also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() @pytest.mark.usefixtures("enable_labs_preview_features") @@ -185,7 +185,6 @@ async def test_text_state_trigger( @pytest.mark.parametrize(("trigger", "states"), TEST_TRIGGER_STATES) async def test_input_text_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_input_texts: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -194,6 +193,7 @@ async def test_input_text_state_trigger( states: list[BasicTriggerStateDescription], ) -> None: """Test that the `text.changed` trigger fires when any input_text entity's state changes.""" + calls: list[str] = [] other_entity_ids = set(target_input_texts["included_entities"]) - {entity_id} # Set all input_texts, including the tested input_text, to the initial state @@ -201,20 +201,20 @@ async def test_input_text_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other input_texts also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() diff --git a/tests/components/update/test_trigger.py b/tests/components/update/test_trigger.py index a4a1dcc00a8..4e66c7774e1 100644 --- a/tests/components/update/test_trigger.py +++ b/tests/components/update/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.update import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -56,7 +56,6 @@ async def test_update_triggers_gated_by_labs_flag( ) async def test_update_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_updates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -68,7 +67,6 @@ async def test_update_state_trigger_behavior_any( """Test that the update state trigger fires when any update state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_updates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -96,7 +94,6 @@ async def test_update_state_trigger_behavior_any( ) async def test_update_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_updates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -108,7 +105,6 @@ async def test_update_state_trigger_behavior_first( """Test that the update state trigger fires when the first update changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_updates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -136,7 +132,6 @@ async def test_update_state_trigger_behavior_first( ) async def test_update_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_updates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -148,7 +143,6 @@ async def test_update_state_trigger_behavior_last( """Test that the update state trigger fires when the last update changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_updates, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/vacuum/test_trigger.py b/tests/components/vacuum/test_trigger.py index ae2f5f95d08..a0c93ee272c 100644 --- a/tests/components/vacuum/test_trigger.py +++ b/tests/components/vacuum/test_trigger.py @@ -5,7 +5,7 @@ from typing import Any import pytest from homeassistant.components.vacuum import VacuumActivity -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -80,7 +80,6 @@ async def test_vacuum_triggers_gated_by_labs_flag( ) async def test_vacuum_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_vacuums: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -92,7 +91,6 @@ async def test_vacuum_state_trigger_behavior_any( """Test that the vacuum state trigger fires when any vacuum state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_vacuums, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -140,7 +138,6 @@ async def test_vacuum_state_trigger_behavior_any( ) async def test_vacuum_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_vacuums: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -152,7 +149,6 @@ async def test_vacuum_state_trigger_behavior_first( """Test that the vacuum state trigger fires when the first vacuum changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_vacuums, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_vacuum_state_trigger_behavior_first( ) async def test_vacuum_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_vacuums: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_vacuum_state_trigger_behavior_last( """Test that the vacuum state trigger fires when the last vacuum changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_vacuums, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/water_heater/test_trigger.py b/tests/components/water_heater/test_trigger.py index 39c2ffad8b9..6c33a35e794 100644 --- a/tests/components/water_heater/test_trigger.py +++ b/tests/components/water_heater/test_trigger.py @@ -13,7 +13,7 @@ from homeassistant.components.water_heater import ( STATE_PERFORMANCE, ) from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, STATE_ON, UnitOfTemperature -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -104,7 +104,6 @@ async def test_water_heater_triggers_gated_by_labs_flag( ) async def test_water_heater_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -116,7 +115,6 @@ async def test_water_heater_state_trigger_behavior_any( """Test that the water heater state trigger fires when any water heater state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -151,7 +149,6 @@ async def test_water_heater_state_trigger_behavior_any( ) async def test_water_heater_state_attribute_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -163,7 +160,6 @@ async def test_water_heater_state_attribute_trigger_behavior_any( """Test that the water heater target temperature attribute triggers fire when any water heater's target temperature changes or crosses a threshold.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -214,7 +210,6 @@ async def test_water_heater_state_attribute_trigger_behavior_any( ) async def test_water_heater_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -226,7 +221,6 @@ async def test_water_heater_state_trigger_behavior_first( """Test that the water heater state trigger fires when the first water heater changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -255,7 +249,6 @@ async def test_water_heater_state_trigger_behavior_first( ) async def test_water_heater_state_attribute_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -267,7 +260,6 @@ async def test_water_heater_state_attribute_trigger_behavior_first( """Test that the water heater attribute threshold trigger fires when the first water heater's target temperature crosses the configured threshold.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -318,7 +310,6 @@ async def test_water_heater_state_attribute_trigger_behavior_first( ) async def test_water_heater_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -330,7 +321,6 @@ async def test_water_heater_state_trigger_behavior_last( """Test that the water heater state trigger fires when the last water heater changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -359,7 +349,6 @@ async def test_water_heater_state_trigger_behavior_last( ) async def test_water_heater_state_attribute_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -371,7 +360,6 @@ async def test_water_heater_state_attribute_trigger_behavior_last( """Test that the water heater trigger fires when the last water heater's target temperature crosses the configured threshold.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/window/test_trigger.py b/tests/components/window/test_trigger.py index c5d110117c0..26ab972cce0 100644 --- a/tests/components/window/test_trigger.py +++ b/tests/components/window/test_trigger.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -77,7 +77,6 @@ async def test_window_triggers_gated_by_labs_flag( ) async def test_window_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -89,7 +88,6 @@ async def test_window_trigger_binary_sensor_behavior_any( """Test window trigger fires for binary_sensor entities with device_class window.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -147,7 +145,6 @@ async def test_window_trigger_binary_sensor_behavior_any( ) async def test_window_trigger_cover_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -159,7 +156,6 @@ async def test_window_trigger_cover_behavior_any( """Test window trigger fires for cover entities with device_class window.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_window_trigger_cover_behavior_any( ) async def test_window_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_window_trigger_binary_sensor_behavior_first( """Test window trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -253,7 +247,6 @@ async def test_window_trigger_binary_sensor_behavior_first( ) async def test_window_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -265,7 +258,6 @@ async def test_window_trigger_binary_sensor_behavior_last( """Test window trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -323,7 +315,6 @@ async def test_window_trigger_binary_sensor_behavior_last( ) async def test_window_trigger_cover_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -335,7 +326,6 @@ async def test_window_trigger_cover_behavior_first( """Test window trigger fires on the first cover state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -393,7 +383,6 @@ async def test_window_trigger_cover_behavior_first( ) async def test_window_trigger_cover_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -405,7 +394,6 @@ async def test_window_trigger_cover_behavior_last( """Test window trigger fires when the last cover changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, From 08f7bed679541d05f60fe8834640ea4d6c2ecf90 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:13:12 +0100 Subject: [PATCH 009/138] Improve humidifier action naming consistency (#166524) --- .../components/humidifier/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 09b01ce14de..beee0502bc0 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -167,36 +167,36 @@ }, "services": { "set_humidity": { - "description": "Sets the target humidity.", + "description": "Sets the target humidity of a humidifier.", "fields": { "humidity": { "description": "Target humidity.", "name": "Humidity" } }, - "name": "Set humidity" + "name": "Set humidifier target humidity" }, "set_mode": { - "description": "Sets the humidifier operation mode.", + "description": "Sets the mode of a humidifier.", "fields": { "mode": { "description": "Operation mode. For example, \"normal\", \"eco\", or \"away\". For a list of possible values, refer to the integration documentation.", "name": "Mode" } }, - "name": "Set mode" + "name": "Set humidifier mode" }, "toggle": { - "description": "Toggles the humidifier on/off.", - "name": "[%key:common::action::toggle%]" + "description": "Toggles a humidifier on/off.", + "name": "Toggle humidifier" }, "turn_off": { - "description": "Turns the humidifier off.", - "name": "[%key:common::action::turn_off%]" + "description": "Turns off a humidifier.", + "name": "Turn off humidifier" }, "turn_on": { - "description": "Turns the humidifier on.", - "name": "[%key:common::action::turn_on%]" + "description": "Turns on a humidifier.", + "name": "Turn on humidifier" } }, "title": "Humidifier", From cb195be6ad99345487120f61af51425309e49477 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:08:49 +0100 Subject: [PATCH 010/138] Improve automation action naming consistency (#166525) --- homeassistant/components/automation/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index b1dd9779ce2..40add9c8d14 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -78,11 +78,11 @@ "services": { "reload": { "description": "Reloads the automation configuration.", - "name": "[%key:common::action::reload%]" + "name": "Reload automations" }, "toggle": { "description": "Toggles (enable / disable) an automation.", - "name": "[%key:common::action::toggle%]" + "name": "Toggle automation" }, "trigger": { "description": "Triggers the actions of an automation.", @@ -92,7 +92,7 @@ "name": "Skip conditions" } }, - "name": "Trigger" + "name": "Trigger automation" }, "turn_off": { "description": "Disables an automation.", @@ -102,11 +102,11 @@ "name": "Stop actions" } }, - "name": "[%key:common::action::turn_off%]" + "name": "Turn off automation" }, "turn_on": { "description": "Enables an automation.", - "name": "[%key:common::action::turn_on%]" + "name": "Turn on automation" } }, "title": "Automation" From 3c52acb8259c47660ca17dae8a8c43179c281a3a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:11:16 +0100 Subject: [PATCH 011/138] Improve counter action naming consistency (#166526) --- homeassistant/components/counter/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index 14ff121969f..e09fd1ba9fd 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -41,25 +41,25 @@ "services": { "decrement": { "description": "Decrements a counter by its step size.", - "name": "Decrement" + "name": "Decrement counter" }, "increment": { "description": "Increments a counter by its step size.", - "name": "Increment" + "name": "Increment counter" }, "reset": { "description": "Resets a counter to its initial value.", - "name": "Reset" + "name": "Reset counter" }, "set_value": { - "description": "Sets the counter to a specific value.", + "description": "Sets a counter to a specific value.", "fields": { "value": { "description": "The new counter value the entity should be set to.", "name": "Value" } }, - "name": "Set" + "name": "Set counter value" } }, "title": "Counter", From d8dee8fc9164bf020d9e0ae980b20c5e58bfeea6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:10:50 +0100 Subject: [PATCH 012/138] Improve image action naming consistency (#166527) --- homeassistant/components/image/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/image/strings.json b/homeassistant/components/image/strings.json index b90665f0e94..dc56df4704f 100644 --- a/homeassistant/components/image/strings.json +++ b/homeassistant/components/image/strings.json @@ -13,7 +13,7 @@ "name": "Filename" } }, - "name": "Take snapshot" + "name": "Take image snapshot" } }, "title": "Image" From 9d793ce1df9ddf757879d55f2270641712905b18 Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Wed, 25 Mar 2026 21:33:24 +0000 Subject: [PATCH 013/138] Bump pyanglianwater to 3.1.2 (#166531) --- .../components/anglian_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/anglian_water/conftest.py | 6 ++-- .../snapshots/test_coordinator.ambr | 34 ++++++++----------- .../anglian_water/snapshots/test_sensor.ambr | 2 +- 6 files changed, 21 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/anglian_water/manifest.json b/homeassistant/components/anglian_water/manifest.json index c81038e9731..71a0fbd9f78 100644 --- a/homeassistant/components/anglian_water/manifest.json +++ b/homeassistant/components/anglian_water/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["pyanglianwater"], "quality_scale": "bronze", - "requirements": ["pyanglianwater==3.1.1"] + "requirements": ["pyanglianwater==3.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 028b32546d1..8ed2c60aab3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1959,7 +1959,7 @@ pyairobotrest==0.3.0 pyairvisual==2023.08.1 # homeassistant.components.anglian_water -pyanglianwater==3.1.1 +pyanglianwater==3.1.2 # homeassistant.components.aprilaire pyaprilaire==0.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42d8acebfda..5f449ab8c12 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1696,7 +1696,7 @@ pyairobotrest==0.3.0 pyairvisual==2023.08.1 # homeassistant.components.anglian_water -pyanglianwater==3.1.1 +pyanglianwater==3.1.2 # homeassistant.components.aprilaire pyaprilaire==0.9.1 diff --git a/tests/components/anglian_water/conftest.py b/tests/components/anglian_water/conftest.py index a482a789455..6a56dfcc761 100644 --- a/tests/components/anglian_water/conftest.py +++ b/tests/components/anglian_water/conftest.py @@ -40,9 +40,9 @@ def mock_smart_meter(freezer: FrozenDateTimeFactory) -> SmartMeter: meter = SmartMeter("TESTSN") meter.readings = [ - {"read_at": "2024-06-01T12:00:00Z", "consumption": 10, "read": 10}, - {"read_at": "2024-06-01T13:00:00Z", "consumption": 15, "read": 25}, - {"read_at": "2024-06-01T14:00:00Z", "consumption": 25, "read": 50}, + {"read_at": "2024-06-01T12:00:00", "consumption": 10, "read": 10}, + {"read_at": "2024-06-01T13:00:00", "consumption": 15, "read": 25}, + {"read_at": "2024-06-01T14:00:00", "consumption": 25, "read": 50}, ] meter.yesterday_water_cost = 0.5 meter.yesterday_sewerage_cost = 0.5 diff --git a/tests/components/anglian_water/snapshots/test_coordinator.ambr b/tests/components/anglian_water/snapshots/test_coordinator.ambr index 8fe7079c5d7..dab378d29c1 100644 --- a/tests/components/anglian_water/snapshots/test_coordinator.ambr +++ b/tests/components/anglian_water/snapshots/test_coordinator.ambr @@ -3,20 +3,20 @@ defaultdict({ 'anglian_water:171266493_testsn_usage': list([ dict({ - 'end': 1717243200.0, - 'start': 1717239600.0, + 'end': 1717268400.0, + 'start': 1717264800.0, 'state': 0.01, 'sum': 10.0, }), dict({ - 'end': 1717246800.0, - 'start': 1717243200.0, + 'end': 1717272000.0, + 'start': 1717268400.0, 'state': 0.015, 'sum': 25.0, }), dict({ - 'end': 1717250400.0, - 'start': 1717246800.0, + 'end': 1717275600.0, + 'start': 1717272000.0, 'state': 0.025, 'sum': 50.0, }), @@ -27,28 +27,22 @@ defaultdict({ 'anglian_water:171266493_testsn_usage': list([ dict({ - 'end': 1717243200.0, - 'start': 1717239600.0, + 'end': 1717268400.0, + 'start': 1717264800.0, 'state': 0.01, 'sum': 10.0, }), dict({ - 'end': 1717246800.0, - 'start': 1717243200.0, + 'end': 1717272000.0, + 'start': 1717268400.0, 'state': 0.015, 'sum': 25.0, }), dict({ - 'end': 1717250400.0, - 'start': 1717246800.0, - 'state': 0.035, - 'sum': 70.0, - }), - dict({ - 'end': 1717254000.0, - 'start': 1717250400.0, - 'state': 0.02, - 'sum': 90.0, + 'end': 1717275600.0, + 'start': 1717272000.0, + 'state': 0.025, + 'sum': 50.0, }), ]), }) diff --git a/tests/components/anglian_water/snapshots/test_sensor.ambr b/tests/components/anglian_water/snapshots/test_sensor.ambr index ef49050f358..58addf7f189 100644 --- a/tests/components/anglian_water/snapshots/test_sensor.ambr +++ b/tests/components/anglian_water/snapshots/test_sensor.ambr @@ -47,7 +47,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-06-01T14:00:00+00:00', + 'state': '2024-06-01T13:00:00+00:00', }) # --- # name: test_sensor[sensor.testsn_latest_reading-entry] From 44bd3e3d7460ea8f2165d4ba10758e0a29caeb9d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:04:37 +0100 Subject: [PATCH 014/138] Improve device tracker action naming consistency (#166534) --- homeassistant/components/device_tracker/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index 67f6eff1c16..f4f7031fa79 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -120,7 +120,7 @@ "name": "MAC address" } }, - "name": "See" + "name": "See device tracker" } }, "title": "Device tracker", From 7ff868e94cc2e07be1d7a5aede5495dc18a9b755 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:01:53 +0100 Subject: [PATCH 015/138] Improve water heater action naming consistency (#166535) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/water_heater/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index df8ce5a1297..46362df0654 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -144,27 +144,27 @@ }, "services": { "set_away_mode": { - "description": "Turns away mode on/off.", + "description": "Sets the away mode of a water heater.", "fields": { "away_mode": { "description": "New value of away mode.", "name": "Away mode" } }, - "name": "Set away mode" + "name": "Set water heater away mode" }, "set_operation_mode": { - "description": "Sets the operation mode.", + "description": "Sets the operation mode of a water heater.", "fields": { "operation_mode": { "description": "[%key:component::water_heater::services::set_temperature::fields::operation_mode::description%]", "name": "[%key:component::water_heater::services::set_temperature::fields::operation_mode::name%]" } }, - "name": "Set operation mode" + "name": "Set water heater operation mode" }, "set_temperature": { - "description": "Sets the target temperature.", + "description": "Sets the target temperature of a water heater.", "fields": { "operation_mode": { "description": "New value of the operation mode. For a list of possible modes, refer to the integration documentation.", @@ -175,15 +175,15 @@ "name": "Temperature" } }, - "name": "Set temperature" + "name": "Set water heater target temperature" }, "turn_off": { - "description": "Turns water heater off.", - "name": "[%key:common::action::turn_off%]" + "description": "Turns off a water heater.", + "name": "Turn off water heater" }, "turn_on": { - "description": "Turns water heater on.", - "name": "[%key:common::action::turn_on%]" + "description": "Turns on a water heater.", + "name": "Turn on water heater" } }, "title": "Water heater", From b802dcba8de639f4f0f5c1e2ecd5524148d5bad5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:03:15 +0100 Subject: [PATCH 016/138] Improve group action naming consistency (#166537) --- homeassistant/components/group/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 5c8aee6b2d2..61dba512e34 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -296,7 +296,7 @@ "services": { "reload": { "description": "Reloads group configuration, entities, and notify services from YAML-configuration.", - "name": "[%key:common::action::reload%]" + "name": "Reload groups" }, "remove": { "description": "Removes a group.", @@ -306,10 +306,10 @@ "name": "[%key:component::group::services::set::fields::object_id::name%]" } }, - "name": "Remove" + "name": "Remove group" }, "set": { - "description": "Creates/Updates a group.", + "description": "Creates or updates a group.", "fields": { "add_entities": { "description": "List of members to be added to the group. Cannot be used in combination with `Entities` or `Remove entities`.", @@ -340,7 +340,7 @@ "name": "Remove entities" } }, - "name": "Set" + "name": "Set group" } }, "title": "Group" From e0455629d77924c816bea3ea30fb539ea5dec21f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:02:16 +0100 Subject: [PATCH 017/138] Improve logger action naming consistency (#166538) --- homeassistant/components/logger/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/logger/strings.json b/homeassistant/components/logger/strings.json index 4fa4195eb81..3b7f650b855 100644 --- a/homeassistant/components/logger/strings.json +++ b/homeassistant/components/logger/strings.json @@ -20,11 +20,11 @@ "name": "Level" } }, - "name": "Set default level" + "name": "Set logger default level" }, "set_level": { "description": "Sets the log level for one or more integrations.", - "name": "Set level" + "name": "Set logger level" } } } From c458bc2ee318748c5388f6250d397d965f4dfa7e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Mar 2026 00:34:08 +0100 Subject: [PATCH 018/138] Improve dashboard action naming consistency (#166539) --- homeassistant/components/lovelace/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lovelace/strings.json b/homeassistant/components/lovelace/strings.json index 49581cbcea6..2f0fa4ccbf1 100644 --- a/homeassistant/components/lovelace/strings.json +++ b/homeassistant/components/lovelace/strings.json @@ -13,7 +13,7 @@ "services": { "reload_resources": { "description": "Reloads dashboard resources from the YAML-configuration.", - "name": "Reload resources" + "name": "Reload dashboard resources" } }, "system_health": { From 9f3917830d44423f4f3472641adea8a9aadb2b91 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:08:29 +0100 Subject: [PATCH 019/138] Improve weather action naming consistency (#166540) --- homeassistant/components/weather/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index cda8b91ff79..02b017a7658 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -101,24 +101,24 @@ }, "services": { "get_forecast": { - "description": "Retrieves the forecast from a selected weather service.", + "description": "Retrieves the forecast from a weather service.", "fields": { "type": { "description": "[%key:component::weather::services::get_forecasts::fields::type::description%]", "name": "[%key:component::weather::services::get_forecasts::fields::type::name%]" } }, - "name": "Get forecast" + "name": "Get weather forecast" }, "get_forecasts": { - "description": "Retrieves the forecast from selected weather services.", + "description": "Retrieves the forecasts from one or more weather services.", "fields": { "type": { "description": "The scope of the weather forecast.", "name": "Forecast type" } }, - "name": "Get forecasts" + "name": "Get weather forecasts" } }, "title": "Weather" From aa9e27902657422a38a375fde69730762abb1a59 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Mar 2026 00:34:22 +0100 Subject: [PATCH 020/138] Improve conversation action naming consistency (#166542) --- homeassistant/components/conversation/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/strings.json b/homeassistant/components/conversation/strings.json index b09d3dccac2..be083c0171d 100644 --- a/homeassistant/components/conversation/strings.json +++ b/homeassistant/components/conversation/strings.json @@ -6,7 +6,7 @@ }, "services": { "process": { - "description": "Launches a conversation from a transcribed text.", + "description": "Sends text to a conversation agent for processing.", "fields": { "agent_id": { "description": "Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands.", @@ -25,10 +25,10 @@ "name": "Text" } }, - "name": "Process" + "name": "Process conversation" }, "reload": { - "description": "Reloads the intent configuration.", + "description": "Reloads the intent configuration of conversation agents.", "fields": { "agent_id": { "description": "Conversation agent to reload.", @@ -39,7 +39,7 @@ "name": "[%key:common::config_flow::data::language%]" } }, - "name": "[%key:common::action::reload%]" + "name": "Reload conversation agents" } }, "title": "Conversation" From 43ca72bf7e484b046078ea96620c09ff4f9ea80b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Mar 2026 00:01:26 +0000 Subject: [PATCH 021/138] Bump version to 2026.4.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 93a789ef712..7341f499355 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index fa18e5eee56..c2d8c7ab971 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0b0" +version = "2026.4.0b1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From eeba0467a13ccbbdcd250e0943f7273520c7d9ee Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 10:03:49 +0100 Subject: [PATCH 022/138] Add trigger humidifier.mode_changed (#166241) Co-authored-by: Norbert Rittel --- .../components/humidifier/icons.json | 3 + .../components/humidifier/strings.json | 14 +++ .../components/humidifier/trigger.py | 62 +++++++++- .../components/humidifier/triggers.yaml | 17 ++- tests/components/humidifier/test_trigger.py | 116 +++++++++++++++++- 5 files changed, 203 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json index fde5c3c9598..778aa6d0f47 100644 --- a/homeassistant/components/humidifier/icons.json +++ b/homeassistant/components/humidifier/icons.json @@ -67,6 +67,9 @@ } }, "triggers": { + "mode_changed": { + "trigger": "mdi:air-humidifier" + }, "started_drying": { "trigger": "mdi:arrow-down-bold" }, diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index beee0502bc0..6acd851b3de 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -201,6 +201,20 @@ }, "title": "Humidifier", "triggers": { + "mode_changed": { + "description": "Triggers after the operation mode of one or more humidifiers changes.", + "fields": { + "behavior": { + "description": "[%key:component::humidifier::common::trigger_behavior_description%]", + "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "mode": { + "description": "The operation modes to trigger on.", + "name": "Mode" + } + }, + "name": "Humidifier mode changed" + }, "started_drying": { "description": "Triggers after one or more humidifiers start drying.", "fields": { diff --git a/homeassistant/components/humidifier/trigger.py b/homeassistant/components/humidifier/trigger.py index 44179856f27..b0df9126733 100644 --- a/homeassistant/components/humidifier/trigger.py +++ b/homeassistant/components/humidifier/trigger.py @@ -1,13 +1,65 @@ """Provides triggers for humidifiers.""" -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec -from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger +import voluptuous as vol + +from homeassistant.const import ATTR_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.entity import get_supported_features +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, + EntityTargetStateTriggerBase, + Trigger, + TriggerConfig, + make_entity_target_state_trigger, +) + +from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature + +CONF_MODE = "mode" + +MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]), + }, + } +) + + +def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool: + """Test if an entity supports the specified features.""" + try: + return bool(get_supported_features(hass, entity_id) & features) + except HomeAssistantError: + return False + + +class ModeChangedTrigger(EntityTargetStateTriggerBase): + """Trigger for humidifier mode changes.""" + + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)} + _schema = MODE_CHANGED_TRIGGER_SCHEMA + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the mode trigger.""" + super().__init__(hass, config) + self._to_states = set(self._options[CONF_MODE]) + + def entity_filter(self, entities: set[str]) -> set[str]: + """Filter entities of this domain.""" + entities = super().entity_filter(entities) + return { + entity_id + for entity_id in entities + if _supports_feature(self._hass, entity_id, HumidifierEntityFeature.MODES) + } -from .const import ATTR_ACTION, DOMAIN, HumidifierAction TRIGGERS: dict[str, type[Trigger]] = { + "mode_changed": ModeChangedTrigger, "started_drying": make_entity_target_state_trigger( {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING ), diff --git a/homeassistant/components/humidifier/triggers.yaml b/homeassistant/components/humidifier/triggers.yaml index 5773f999c88..12072ab71eb 100644 --- a/homeassistant/components/humidifier/triggers.yaml +++ b/homeassistant/components/humidifier/triggers.yaml @@ -1,9 +1,9 @@ .trigger_common: &trigger_common - target: + target: &trigger_humidifier_target entity: domain: humidifier fields: - behavior: + behavior: &trigger_behavior required: true default: any selector: @@ -18,3 +18,16 @@ started_drying: *trigger_common started_humidifying: *trigger_common turned_on: *trigger_common turned_off: *trigger_common + +mode_changed: + target: *trigger_humidifier_target + fields: + behavior: *trigger_behavior + mode: + context: + filter_target: target + required: true + selector: + state: + attribute: available_modes + multiple: true diff --git a/tests/components/humidifier/test_trigger.py b/tests/components/humidifier/test_trigger.py index fde5ed83a63..81af5d51afa 100644 --- a/tests/components/humidifier/test_trigger.py +++ b/tests/components/humidifier/test_trigger.py @@ -1,12 +1,28 @@ """Test humidifier trigger.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from typing import Any import pytest +import voluptuous as vol -from homeassistant.components.humidifier.const import ATTR_ACTION, HumidifierAction -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.humidifier.const import ( + ATTR_ACTION, + HumidifierAction, + HumidifierEntityFeature, +) +from homeassistant.components.humidifier.trigger import CONF_MODE +from homeassistant.const import ( + ATTR_MODE, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITY_ID, + CONF_OPTIONS, + CONF_TARGET, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import async_validate_trigger_config from tests.components.common import ( TriggerStateDescription, @@ -29,6 +45,7 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]: @pytest.mark.parametrize( "trigger_key", [ + "humidifier.mode_changed", "humidifier.started_drying", "humidifier.started_humidifying", "humidifier.turned_off", @@ -103,6 +120,21 @@ async def test_humidifier_state_trigger_behavior_any( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_trigger_states( + trigger="humidifier.mode_changed", + trigger_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + trigger_from_none=False, + ), ], ) async def test_humidifier_state_attribute_trigger_behavior_any( @@ -189,6 +221,21 @@ async def test_humidifier_state_trigger_behavior_first( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_trigger_states( + trigger="humidifier.mode_changed", + trigger_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + trigger_from_none=False, + ), ], ) async def test_humidifier_state_attribute_trigger_behavior_first( @@ -275,6 +322,21 @@ async def test_humidifier_state_trigger_behavior_last( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_trigger_states( + trigger="humidifier.mode_changed", + trigger_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + trigger_from_none=False, + ), ], ) async def test_humidifier_state_attribute_trigger_behavior_last( @@ -298,3 +360,53 @@ async def test_humidifier_state_attribute_trigger_behavior_last( trigger_options=trigger_options, states=states, ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger", "trigger_options", "expected_result"), + [ + # Valid configurations + ( + "humidifier.mode_changed", + {CONF_MODE: ["eco", "sleep"]}, + does_not_raise(), + ), + ( + "humidifier.mode_changed", + {CONF_MODE: "eco"}, + does_not_raise(), + ), + # Invalid configurations + ( + "humidifier.mode_changed", + # Empty mode list + {CONF_MODE: []}, + pytest.raises(vol.Invalid), + ), + ( + "humidifier.mode_changed", + # Missing CONF_MODE + {}, + pytest.raises(vol.Invalid), + ), + ], +) +async def test_humidifier_mode_changed_trigger_validation( + hass: HomeAssistant, + trigger: str, + trigger_options: dict[str, Any], + expected_result: AbstractContextManager, +) -> None: + """Test humidifier mode_changed trigger config validation.""" + with expected_result: + await async_validate_trigger_config( + hass, + [ + { + "platform": trigger, + CONF_TARGET: {CONF_ENTITY_ID: "humidifier.test"}, + CONF_OPTIONS: trigger_options, + } + ], + ) From cdf7b013a9f721ff4f9671b58b4f802ea4895fa7 Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:51:49 +0100 Subject: [PATCH 023/138] Add battery triggers (#166258) --- .../components/automation/__init__.py | 1 + homeassistant/components/battery/__init__.py | 2 +- homeassistant/components/battery/icons.json | 20 + homeassistant/components/battery/strings.json | 82 ++- homeassistant/components/battery/trigger.py | 56 ++ .../components/battery/triggers.yaml | 85 +++ tests/components/battery/test_trigger.py | 485 ++++++++++++++++++ 7 files changed, 728 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/battery/trigger.py create mode 100644 homeassistant/components/battery/triggers.yaml create mode 100644 tests/components/battery/test_trigger.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 79109168157..b5fc6b5f015 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -155,6 +155,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "air_quality", "alarm_control_panel", "assist_satellite", + "battery", "button", "climate", "counter", diff --git a/homeassistant/components/battery/__init__.py b/homeassistant/components/battery/__init__.py index ec07ec5e77e..52644072bba 100644 --- a/homeassistant/components/battery/__init__.py +++ b/homeassistant/components/battery/__init__.py @@ -1,4 +1,4 @@ -"""Integration for battery conditions.""" +"""Integration for battery triggers and conditions.""" from __future__ import annotations diff --git a/homeassistant/components/battery/icons.json b/homeassistant/components/battery/icons.json index 64ec4dc8f3f..f3c9e2b9385 100644 --- a/homeassistant/components/battery/icons.json +++ b/homeassistant/components/battery/icons.json @@ -15,5 +15,25 @@ "is_not_low": { "condition": "mdi:battery" } + }, + "triggers": { + "level_changed": { + "trigger": "mdi:battery-unknown" + }, + "level_crossed_threshold": { + "trigger": "mdi:battery-alert" + }, + "low": { + "trigger": "mdi:battery-alert" + }, + "not_low": { + "trigger": "mdi:battery" + }, + "started_charging": { + "trigger": "mdi:battery-charging" + }, + "stopped_charging": { + "trigger": "mdi:battery" + } } } diff --git a/homeassistant/components/battery/strings.json b/homeassistant/components/battery/strings.json index e0eec43b74e..dc6c518f665 100644 --- a/homeassistant/components/battery/strings.json +++ b/homeassistant/components/battery/strings.json @@ -3,7 +3,12 @@ "condition_behavior_description": "How the state should match on the targeted batteries.", "condition_behavior_name": "Behavior", "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration" + "condition_threshold_name": "Threshold configuration", + "trigger_behavior_description": "The behavior of the targeted batteries to trigger on.", + "trigger_behavior_name": "Behavior", + "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", + "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", + "trigger_threshold_name": "Threshold configuration" }, "conditions": { "is_charging": { @@ -67,7 +72,80 @@ "all": "All", "any": "Any" } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } } }, - "title": "Battery" + "title": "Battery", + "triggers": { + "level_changed": { + "description": "Triggers after the battery level of one or more batteries changes.", + "fields": { + "threshold": { + "description": "[%key:component::battery::common::trigger_threshold_changed_description%]", + "name": "[%key:component::battery::common::trigger_threshold_name%]" + } + }, + "name": "Battery level changed" + }, + "level_crossed_threshold": { + "description": "Triggers after the battery level of one or more batteries crosses a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::battery::common::trigger_behavior_description%]", + "name": "[%key:component::battery::common::trigger_behavior_name%]" + }, + "threshold": { + "description": "[%key:component::battery::common::trigger_threshold_crossed_description%]", + "name": "[%key:component::battery::common::trigger_threshold_name%]" + } + }, + "name": "Battery level crossed threshold" + }, + "low": { + "description": "Triggers after one or more batteries become low.", + "fields": { + "behavior": { + "description": "[%key:component::battery::common::trigger_behavior_description%]", + "name": "[%key:component::battery::common::trigger_behavior_name%]" + } + }, + "name": "Battery low" + }, + "not_low": { + "description": "Triggers after one or more batteries are no longer low.", + "fields": { + "behavior": { + "description": "[%key:component::battery::common::trigger_behavior_description%]", + "name": "[%key:component::battery::common::trigger_behavior_name%]" + } + }, + "name": "Battery not low" + }, + "started_charging": { + "description": "Triggers after one or more batteries start charging.", + "fields": { + "behavior": { + "description": "[%key:component::battery::common::trigger_behavior_description%]", + "name": "[%key:component::battery::common::trigger_behavior_name%]" + } + }, + "name": "Battery started charging" + }, + "stopped_charging": { + "description": "Triggers after one or more batteries stop charging.", + "fields": { + "behavior": { + "description": "[%key:component::battery::common::trigger_behavior_description%]", + "name": "[%key:component::battery::common::trigger_behavior_name%]" + } + }, + "name": "Battery stopped charging" + } + } } diff --git a/homeassistant/components/battery/trigger.py b/homeassistant/components/battery/trigger.py new file mode 100644 index 00000000000..ff4d681c5d3 --- /dev/null +++ b/homeassistant/components/battery/trigger.py @@ -0,0 +1,56 @@ +"""Provides triggers for batteries.""" + +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.trigger import ( + Trigger, + make_entity_numerical_state_changed_trigger, + make_entity_numerical_state_crossed_threshold_trigger, + make_entity_target_state_trigger, +) + +BATTERY_LOW_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.BATTERY), +} + +BATTERY_CHARGING_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec( + device_class=BinarySensorDeviceClass.BATTERY_CHARGING + ), +} + +BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { + SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.BATTERY), + NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.BATTERY), +} + +TRIGGERS: dict[str, type[Trigger]] = { + "low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON), + "not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF), + "started_charging": make_entity_target_state_trigger( + BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON + ), + "stopped_charging": make_entity_target_state_trigger( + BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF + ), + "level_changed": make_entity_numerical_state_changed_trigger( + BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%" + ), + "level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%" + ), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for batteries.""" + return TRIGGERS diff --git a/homeassistant/components/battery/triggers.yaml b/homeassistant/components/battery/triggers.yaml new file mode 100644 index 00000000000..a8f64995d3f --- /dev/null +++ b/homeassistant/components/battery/triggers.yaml @@ -0,0 +1,85 @@ +.trigger_common_fields: + behavior: &trigger_behavior + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +.battery_threshold_entity: &battery_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: number + device_class: battery + - domain: sensor + device_class: battery + +.battery_threshold_number: &battery_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" + +.trigger_target_battery: &trigger_target_battery + entity: + - domain: binary_sensor + device_class: battery + +.trigger_target_charging: &trigger_target_charging + entity: + - domain: binary_sensor + device_class: battery_charging + +.trigger_target_percentage: &trigger_target_percentage + entity: + - domain: sensor + device_class: battery + - domain: number + device_class: battery + +low: + fields: + behavior: *trigger_behavior + target: *trigger_target_battery + +not_low: + fields: + behavior: *trigger_behavior + target: *trigger_target_battery + +started_charging: + fields: + behavior: *trigger_behavior + target: *trigger_target_charging + +stopped_charging: + fields: + behavior: *trigger_behavior + target: *trigger_target_charging + +level_changed: + target: *trigger_target_percentage + fields: + threshold: + required: true + selector: + numeric_threshold: + entity: *battery_threshold_entity + mode: changed + number: *battery_threshold_number + +level_crossed_threshold: + target: *trigger_target_percentage + fields: + behavior: *trigger_behavior + threshold: + required: true + selector: + numeric_threshold: + entity: *battery_threshold_entity + mode: crossed + number: *battery_threshold_number diff --git a/tests/components/battery/test_trigger.py b/tests/components/battery/test_trigger.py new file mode 100644 index 00000000000..16836f4bc35 --- /dev/null +++ b/tests/components/battery/test_trigger.py @@ -0,0 +1,485 @@ +"""Test battery triggers.""" + +from typing import Any + +import pytest + +from homeassistant.components.number import NumberDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant + +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, + parametrize_trigger_states, + target_entities, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@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.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.mark.parametrize( + "trigger_key", + [ + "battery.low", + "battery.not_low", + "battery.started_charging", + "battery.stopped_charging", + "battery.level_changed", + "battery.level_crossed_threshold", + ], +) +async def test_battery_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the battery 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("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="battery.low", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.not_low", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.started_charging", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.stopped_charging", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + ], +) +async def test_battery_binary_sensor_trigger_behavior_any( + hass: HomeAssistant, + target_binary_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 the battery binary sensor triggers with 'any' behavior.""" + await assert_trigger_behavior_any( + hass, + target_entities=target_binary_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("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="battery.low", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.not_low", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.started_charging", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.stopped_charging", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + ], +) +async def test_battery_binary_sensor_trigger_behavior_first( + hass: HomeAssistant, + target_binary_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 the battery binary sensor triggers with 'first' behavior.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_binary_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("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="battery.low", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.not_low", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.started_charging", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.stopped_charging", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + ], +) +async def test_battery_binary_sensor_trigger_behavior_last( + hass: HomeAssistant, + target_binary_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 the battery binary sensor triggers with 'last' behavior.""" + await assert_trigger_behavior_last( + hass, + target_entities=target_binary_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_changed_trigger_states( + "battery.level_changed", + device_class=SensorDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "battery.level_crossed_threshold", + device_class=SensorDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_sensor_trigger_behavior_any( + hass: HomeAssistant, + 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 battery sensor triggers with 'any' behavior.""" + await assert_trigger_behavior_any( + hass, + 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( + "battery.level_crossed_threshold", + device_class=SensorDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_level_crossed_threshold_sensor_behavior_first( + hass: HomeAssistant, + 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 battery level_crossed_threshold trigger fires on the first sensor state change.""" + await assert_trigger_behavior_first( + hass, + 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( + "battery.level_crossed_threshold", + device_class=SensorDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_level_crossed_threshold_sensor_behavior_last( + hass: HomeAssistant, + 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 battery level_crossed_threshold trigger fires when the last sensor changes state.""" + await assert_trigger_behavior_last( + hass, + 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("number"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_changed_trigger_states( + "battery.level_changed", + device_class=NumberDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "battery.level_crossed_threshold", + device_class=NumberDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_number_trigger_behavior_any( + hass: HomeAssistant, + 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 battery number triggers with 'any' behavior.""" + await assert_trigger_behavior_any( + hass, + 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( + "battery.level_crossed_threshold", + device_class=NumberDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_level_crossed_threshold_number_behavior_first( + hass: HomeAssistant, + 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 battery level_crossed_threshold trigger fires on the first number state change.""" + await assert_trigger_behavior_first( + hass, + 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( + "battery.level_crossed_threshold", + device_class=NumberDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_level_crossed_threshold_number_behavior_last( + hass: HomeAssistant, + 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 battery level_crossed_threshold trigger fires when the last number changes state.""" + await assert_trigger_behavior_last( + hass, + 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, + ) From ab2bcd84c6b02d2905cb8e9969e42c1919e62980 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 26 Mar 2026 02:31:07 -0700 Subject: [PATCH 024/138] Add Google Drive backup upload progress (#166549) --- .../components/google_drive/backup.py | 17 ++++++- tests/components/google_drive/test_backup.py | 49 ++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index e6967d95eaf..40ebc7c7cec 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps import logging from typing import Any @@ -84,8 +85,22 @@ class GoogleDriveBackupAgent(BackupAgent): :param open_stream: A function returning an async iterator that yields bytes. :param backup: Metadata about the backup that should be uploaded. """ + + @wraps(open_stream) + async def wrapped_open_stream() -> AsyncIterator[bytes]: + stream = await open_stream() + + async def _progress_stream() -> AsyncIterator[bytes]: + bytes_uploaded = 0 + async for chunk in stream: + yield chunk + bytes_uploaded += len(chunk) + on_progress(bytes_uploaded=bytes_uploaded) + + return _progress_stream() + try: - await self._client.async_upload_backup(open_stream, backup) + await self._client.async_upload_backup(wrapped_open_stream, backup) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: raise BackupAgentError(f"Failed to upload backup: {err}") from err diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index b731be0c34e..48e2b72878a 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -1,5 +1,6 @@ """Test the Google Drive backup platform.""" +from collections.abc import AsyncIterator from io import StringIO import json from typing import Any @@ -16,6 +17,7 @@ from homeassistant.components.backup import ( AgentBackup, ) from homeassistant.components.google_drive import DOMAIN +from homeassistant.components.google_drive.backup import GoogleDriveBackupAgent from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -59,6 +61,18 @@ TEST_AGENT_BACKUP_RESULT = { } +async def consume_stream( + file_metadata: Any, + open_stream: Any, + *args: Any, + **kwargs: Any, +) -> None: + """Consume the stream from the open_stream callable.""" + stream = await open_stream() + async for _ in stream: + pass + + @pytest.fixture(autouse=True) async def setup_integration( hass: HomeAssistant, @@ -283,7 +297,7 @@ async def test_agents_upload( snapshot: SnapshotAssertion, ) -> None: """Test agent upload backup.""" - mock_api.resumable_upload_file = AsyncMock(return_value=None) + mock_api.resumable_upload_file = AsyncMock(side_effect=consume_stream) client = await hass_client() @@ -324,7 +338,7 @@ async def test_agents_upload_create_folder_if_missing( mock_api.create_file = AsyncMock( return_value={"id": "new folder id", "name": "Home Assistant"} ) - mock_api.resumable_upload_file = AsyncMock(return_value=None) + mock_api.resumable_upload_file = AsyncMock(side_effect=consume_stream) client = await hass_client() @@ -354,6 +368,37 @@ async def test_agents_upload_create_folder_if_missing( assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot +async def test_agents_upload_progress( + hass: HomeAssistant, + mock_api: MagicMock, +) -> None: + """Test agent upload reports progress.""" + mock_api.resumable_upload_file = AsyncMock(side_effect=consume_stream) + + entries = hass.config_entries.async_entries(DOMAIN) + agent = GoogleDriveBackupAgent(entries[0]) + + progress_calls = [] + + def on_progress(*, bytes_uploaded: int, **kwargs: Any) -> None: + progress_calls.append(bytes_uploaded) + + async def open_stream() -> AsyncIterator[bytes]: + async def stream() -> AsyncIterator[bytes]: + yield b"chunk1" + yield b"chunk2" + + return stream() + + await agent.async_upload_backup( + open_stream=open_stream, + backup=TEST_AGENT_BACKUP, + on_progress=on_progress, + ) + + assert progress_calls == [6, 12] + + async def test_agents_upload_fail( hass: HomeAssistant, hass_client: ClientSessionGenerator, From f3e8ac5b8ecb22645b1836a628a176890767d4c0 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Thu, 26 Mar 2026 05:27:02 -0400 Subject: [PATCH 025/138] Bump sense-energy to 0.14.0 (#166550) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 3e9d6c81881..2a517aee359 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.8"] + "requirements": ["sense-energy==0.14.0"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 351d3bea7c2..3816a8c4ff9 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -21,5 +21,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.8"] + "requirements": ["sense-energy==0.14.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8ed2c60aab3..59f5cf1de7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2902,7 +2902,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.8 +sense-energy==0.14.0 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f449ab8c12..259e681943e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2459,7 +2459,7 @@ securetar==2026.2.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.8 +sense-energy==0.14.0 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From 35c4b4ff5ba1465b1f115eeac7f0526555dd8851 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Thu, 26 Mar 2026 05:25:29 -0400 Subject: [PATCH 026/138] Bump asyncsleepiq to 1.7.1 (#166552) --- homeassistant/components/sleepiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index 39a889997f8..9cecbfbbcec 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.7.0"] + "requirements": ["asyncsleepiq==1.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 59f5cf1de7a..9fa559c5d34 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -562,7 +562,7 @@ asyncinotify==4.4.0 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.7.0 +asyncsleepiq==1.7.1 # homeassistant.components.sftp_storage asyncssh==2.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 259e681943e..cd19dff5a5b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -523,7 +523,7 @@ async-upnp-client==0.46.2 asyncarve==0.1.1 # homeassistant.components.sleepiq -asyncsleepiq==1.7.0 +asyncsleepiq==1.7.1 # homeassistant.components.sftp_storage asyncssh==2.21.0 From d501d8cb289606b8df4b2dcbd3b68e8bbdf46035 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 11:32:39 +0100 Subject: [PATCH 027/138] Adjust some trigger and condition schemas (#166568) --- homeassistant/components/text/condition.py | 12 ++---- .../components/water_heater/condition.py | 11 +---- homeassistant/helpers/condition.py | 42 +++++++------------ homeassistant/helpers/trigger.py | 39 +++++++---------- 4 files changed, 35 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/text/condition.py b/homeassistant/components/text/condition.py index 3945488d666..7fe4ee44568 100644 --- a/homeassistant/components/text/condition.py +++ b/homeassistant/components/text/condition.py @@ -5,14 +5,12 @@ from typing import TYPE_CHECKING import voluptuous as vol from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN -from homeassistant.const import CONF_OPTIONS, CONF_TARGET +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.condition import ( - ATTR_BEHAVIOR, - BEHAVIOR_ALL, - BEHAVIOR_ANY, + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, ConditionConfig, EntityConditionBase, @@ -22,13 +20,9 @@ from .const import DOMAIN CONF_VALUE = "value" -_TEXT_CONDITION_SCHEMA = vol.Schema( +_TEXT_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( { - vol.Required(CONF_TARGET): cv.TARGET_FIELDS, vol.Required(CONF_OPTIONS): { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_ANY, BEHAVIOR_ALL] - ), vol.Required(CONF_VALUE): cv.string, }, } diff --git a/homeassistant/components/water_heater/condition.py b/homeassistant/components/water_heater/condition.py index 64f4d128954..ce1f36c5269 100644 --- a/homeassistant/components/water_heater/condition.py +++ b/homeassistant/components/water_heater/condition.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.const import ( ATTR_TEMPERATURE, CONF_OPTIONS, - CONF_TARGET, STATE_OFF, UnitOfTemperature, ) @@ -17,9 +16,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec from homeassistant.helpers.condition import ( - ATTR_BEHAVIOR, - BEHAVIOR_ALL, - BEHAVIOR_ANY, + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, ConditionConfig, EntityConditionBase, @@ -33,13 +30,9 @@ from .const import DOMAIN ATTR_OPERATION_MODE = "operation_mode" -_OPERATION_MODE_CONDITION_SCHEMA = vol.Schema( +_OPERATION_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( { - vol.Required(CONF_TARGET): cv.TARGET_FIELDS, vol.Required(CONF_OPTIONS): { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_ANY, BEHAVIOR_ALL] - ), vol.Required(ATTR_OPERATION_MODE): vol.All( cv.ensure_list, vol.Length(min=1), [str] ), diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 967ddefe1b8..19537a20f0b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -462,19 +462,13 @@ def make_entity_state_condition( return CustomCondition -NUMERICAL_CONDITION_SCHEMA = vol.Schema( +NUMERICAL_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( { - vol.Required(CONF_TARGET): cv.TARGET_FIELDS, - vol.Required(CONF_OPTIONS): vol.All( - { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_ANY, BEHAVIOR_ALL] - ), - vol.Required("threshold"): NumericThresholdSelector( - NumericThresholdSelectorConfig(mode=NumericThresholdMode.IS) - ), - }, - ), + vol.Required(CONF_OPTIONS): { + vol.Required("threshold"): NumericThresholdSelector( + NumericThresholdSelectorConfig(mode=NumericThresholdMode.IS) + ), + }, } ) @@ -588,22 +582,16 @@ def _make_numerical_condition_with_unit_schema( unit_converter: type[BaseUnitConverter], ) -> vol.Schema: """Factory for numerical condition schema with unit option.""" - return vol.Schema( + return ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( { - vol.Required(CONF_TARGET): cv.TARGET_FIELDS, - vol.Required(CONF_OPTIONS): vol.All( - { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_ANY, BEHAVIOR_ALL] - ), - vol.Required("threshold"): NumericThresholdSelector( - NumericThresholdSelectorConfig( - mode=NumericThresholdMode.IS, - unit_of_measurement=list(unit_converter.VALID_UNITS), - ) - ), - }, - ), + vol.Required(CONF_OPTIONS): { + vol.Required("threshold"): NumericThresholdSelector( + NumericThresholdSelectorConfig( + mode=NumericThresholdMode.IS, + unit_of_measurement=list(unit_converter.VALID_UNITS), + ) + ), + }, } ) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index fefbc416cb1..975c3dddc3c 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -336,7 +336,6 @@ ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST = ENTITY_STATE_TRIGGER_SCHEMA.extend( [BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY] ), }, - vol.Required(CONF_TARGET): cv.TARGET_FIELDS, } ) @@ -746,19 +745,16 @@ class EntityNumericalStateChangedTriggerWithUnitBase( cls._schema = make_numerical_state_changed_with_unit_schema(cls._unit_converter) -NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( - { - vol.Required(CONF_OPTIONS): vol.All( - { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY] - ), +NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = ( + ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( + { + vol.Required(CONF_OPTIONS): { vol.Required("threshold"): NumericThresholdSelector( NumericThresholdSelectorConfig(mode=NumericThresholdMode.CROSSED) ), }, - ) - } + } + ) ) @@ -787,21 +783,16 @@ def _make_numerical_state_crossed_threshold_with_unit_schema( This trigger only fires when the observed attribute changes from not within to within the defined threshold. """ - return ENTITY_STATE_TRIGGER_SCHEMA.extend( + return ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( { - vol.Required(CONF_OPTIONS, default={}): vol.All( - { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY] - ), - vol.Required("threshold"): NumericThresholdSelector( - NumericThresholdSelectorConfig( - mode=NumericThresholdMode.CROSSED, - unit_of_measurement=list(unit_converter.VALID_UNITS), - ) - ), - }, - ) + vol.Required(CONF_OPTIONS, default={}): { + vol.Required("threshold"): NumericThresholdSelector( + NumericThresholdSelectorConfig( + mode=NumericThresholdMode.CROSSED, + unit_of_measurement=list(unit_converter.VALID_UNITS), + ) + ), + }, } ) From 72f5a572eb2e119e215d42889d3687052eb71d35 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 26 Mar 2026 11:45:34 +0100 Subject: [PATCH 028/138] Revert: Create repair issue for legacy Z-Wave Door state sensors that are still in use (#166583) --- .../components/zwave_js/binary_sensor.py | 103 +------ .../components/zwave_js/strings.json | 4 - .../components/zwave_js/test_binary_sensor.py | 252 +----------------- 3 files changed, 7 insertions(+), 352 deletions(-) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 1af91d168f5..c0df675a25d 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -18,27 +18,17 @@ from zwave_js_server.const.command_class.notification import ( ) from zwave_js_server.model.driver import Driver -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) -from homeassistant.helpers.start import async_at_started from .const import DOMAIN from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity @@ -413,93 +403,6 @@ def is_valid_notification_binary_sensor( return len(info.primary_value.metadata.states) > 1 -@callback -def _async_check_legacy_entity_repair( - hass: HomeAssistant, - driver: Driver, - entity: ZWaveLegacyDoorStateBinarySensor, -) -> None: - """Schedule a repair issue check once HA has fully started.""" - - @callback - def _async_do_check(hass: HomeAssistant) -> None: - """Create or delete a repair issue for a deprecated legacy door state entity.""" - ent_reg = er.async_get(hass) - if entity.unique_id is None: - return - entity_id = ent_reg.async_get_entity_id( - BINARY_SENSOR_DOMAIN, DOMAIN, entity.unique_id - ) - if entity_id is None: - return - - issue_id = f"deprecated_legacy_door_state.{entity_id}" - - # Delete any stale repair issue if the entity is disabled or missing — - # the user has already dealt with it. - entity_entry = ent_reg.async_get(entity_id) - if entity_entry is None or entity_entry.disabled: - async_delete_issue(hass, DOMAIN, issue_id) - return - - entity_automations = automations_with_entity(hass, entity_id) - entity_scripts = scripts_with_entity(hass, entity_id) - - # Delete any stale repair issue if the entity is no longer referenced - # in any automation or script. - if not entity_automations and not entity_scripts: - async_delete_issue(hass, DOMAIN, issue_id) - return - - opening_state_value = get_opening_state_notification_value( - entity.info.node, entity.info.primary_value.endpoint - ) - if opening_state_value is None: - async_delete_issue(hass, DOMAIN, issue_id) - return - opening_state_unique_id = ( - f"{driver.controller.home_id}.{opening_state_value.value_id}" - ) - opening_state_entity_id = ent_reg.async_get_entity_id( - SENSOR_DOMAIN, DOMAIN, opening_state_unique_id - ) - # Delete any stale repair issue if the replacement opening state sensor - # no longer exists for some reason - if opening_state_entity_id is None: - async_delete_issue(hass, DOMAIN, issue_id) - return - - items = [ - f"- [{item.name or item.original_name or eid}](/config/{domain}/edit/{item.unique_id})" - for domain, entity_ids in ( - ("automation", entity_automations), - ("script", entity_scripts), - ) - for eid in entity_ids - if (item := ent_reg.async_get(eid)) - ] - - async_create_issue( - hass, - DOMAIN, - issue_id, - is_fixable=False, - is_persistent=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_legacy_door_state", - translation_placeholders={ - "entity_id": entity_id, - "entity_name": entity_entry.name - or entity_entry.original_name - or entity_id, - "opening_state_entity_id": opening_state_entity_id, - "items": "\n".join(items), - }, - ) - - async_at_started(hass, _async_do_check) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, @@ -543,9 +446,9 @@ async def async_setup_entry( isinstance(info, NewZwaveDiscoveryInfo) and info.entity_class is ZWaveLegacyDoorStateBinarySensor ): - entity = ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) - entities.append(entity) - _async_check_legacy_entity_repair(hass, driver, entity) + entities.append( + ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) + ) elif isinstance(info, NewZwaveDiscoveryInfo): pass # other entity classes are not migrated yet elif info.platform_hint == "notification": diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 21db14ed598..dbaefc4f1cf 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -303,10 +303,6 @@ } }, "issues": { - "deprecated_legacy_door_state": { - "description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the opening state sensor `{opening_state_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the opening state sensor `{opening_state_entity_id}` and disable the binary sensor `{entity_id}` to fix this issue.\n\nNote that `{opening_state_entity_id}` reports three states:\n- Closed\n- Open\n- Tilted (if supported by the device).", - "title": "Deprecation: {entity_name}" - }, "device_config_file_changed": { "fix_flow": { "abort": { diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 9a1ac6ad6b3..ad7db02950c 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -7,14 +7,9 @@ from unittest.mock import MagicMock import pytest from zwave_js_server.event import Event -from zwave_js_server.model.node import Node, NodeDataType +from zwave_js_server.model.node import Node -from homeassistant.components import automation -from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, - BinarySensorDeviceClass, -) -from homeassistant.components.zwave_js.const import DOMAIN +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -25,9 +20,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .common import ( @@ -36,6 +29,7 @@ from .common import ( NOTIFICATION_MOTION_BINARY_SENSOR, PROPERTY_DOOR_STATUS_BINARY_SENSOR, TAMPER_SENSOR, + NodeDataType, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -989,241 +983,3 @@ async def test_hoppe_ehandle_connectsense( assert entry.original_name == "Window/door is tilted" assert entry.original_device_class == BinarySensorDeviceClass.WINDOW assert entry.disabled_by is None, "Entity should be enabled by default" - - -async def test_legacy_door_state_repair_issue( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - client: MagicMock, - hoppe_ehandle_connectsense_state: NodeDataType, -) -> None: - """Test repair issue is created only when legacy door state entity is in automation.""" - node = Node(client, hoppe_ehandle_connectsense_state) - client.driver.controller.nodes[node.node_id] = node - home_id = client.driver.controller.home_id - - # Pre-register the legacy entity as enabled (simulating existing user entity). - unique_id = f"{home_id}.20-113-0-Access Control-Door state.22" - entity_entry = entity_registry.async_get_or_create( - BINARY_SENSOR_DOMAIN, - DOMAIN, - unique_id, - suggested_object_id="ehandle_connectsense_window_door_is_open", - original_name="Window/door is open", - ) - entity_id = entity_entry.entity_id - - # Load the integration without any automation referencing the entity. - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # No repair issues should exist without automations. - issues = [ - issue - for issue in issue_registry.issues.values() - if issue.domain == DOMAIN - and issue.translation_key == "deprecated_legacy_door_state" - ] - assert len(issues) == 0 - - # Now set up an automation referencing the legacy entity. - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "test_automation", - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": {"entity_id": "automation.test_automation"}, - }, - } - }, - ) - - # Reload the integration so the repair check runs again. - await hass.config_entries.async_reload(entry.entry_id) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue( - DOMAIN, f"deprecated_legacy_door_state.{entity_id}" - ) - assert issue is not None - assert issue.translation_key == "deprecated_legacy_door_state" - assert issue.translation_placeholders["entity_id"] == entity_id - assert issue.translation_placeholders["entity_name"] == "Window/door is open" - assert ( - issue.translation_placeholders["opening_state_entity_id"] - == "sensor.ehandle_connectsense_opening_state" - ) - assert "test" in issue.translation_placeholders["items"] - - -async def test_legacy_door_state_no_repair_issue_when_disabled( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - client: MagicMock, - hoppe_ehandle_connectsense_state: NodeDataType, -) -> None: - """Test no repair issue when legacy door state entity is disabled.""" - node = Node(client, hoppe_ehandle_connectsense_state) - client.driver.controller.nodes[node.node_id] = node - home_id = client.driver.controller.home_id - - # Pre-register the legacy entity as disabled. - unique_id = f"{home_id}.20-113-0-Access Control-Door state.22" - entity_entry = entity_registry.async_get_or_create( - BINARY_SENSOR_DOMAIN, - DOMAIN, - unique_id, - suggested_object_id="ehandle_connectsense_window_door_is_open", - original_name="Window/door is open", - disabled_by=er.RegistryEntryDisabler.INTEGRATION, - ) - entity_id = entity_entry.entity_id - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "test_automation", - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": {"entity_id": "automation.test_automation"}, - }, - } - }, - ) - - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # No repair issue should be created since the entity is disabled. - issue = issue_registry.async_get_issue( - DOMAIN, f"deprecated_legacy_door_state.{entity_id}" - ) - assert issue is None - - -async def test_hoppe_custom_tilt_sensor_no_repair_issue( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - client: MagicMock, - hoppe_ehandle_connectsense_state: NodeDataType, -) -> None: - """Test no repair issue for Hoppe eHandle custom tilt sensor (Binary Sensor CC).""" - node = Node(client, hoppe_ehandle_connectsense_state) - client.driver.controller.nodes[node.node_id] = node - - # Pre-register the Hoppe tilt entity as enabled (simulating existing user entity). - home_id = client.driver.controller.home_id - unique_id = f"{home_id}.20-48-0-Tilt" - entity_entry = entity_registry.async_get_or_create( - BINARY_SENSOR_DOMAIN, - DOMAIN, - unique_id, - suggested_object_id="ehandle_connectsense_window_door_is_tilted", - original_name="Window/door is tilted", - ) - entity_id = entity_entry.entity_id - - # Set up automation referencing the custom tilt entity. - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "test_automation", - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": {"entity_id": "automation.test_automation"}, - }, - } - }, - ) - - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # No repair issue should be created - this is a custom Binary Sensor CC entity, - # not a legacy Notification CC door state entity. - issue = issue_registry.async_get_issue( - DOMAIN, f"deprecated_legacy_door_state.{entity_id}" - ) - assert issue is None - - -async def test_legacy_door_state_stale_repair_issue_cleaned_up( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - client: MagicMock, - hoppe_ehandle_connectsense_state: NodeDataType, -) -> None: - """Test that a stale repair issue is deleted when there are no automations.""" - node = Node(client, hoppe_ehandle_connectsense_state) - client.driver.controller.nodes[node.node_id] = node - home_id = client.driver.controller.home_id - - # Pre-register the legacy entity as enabled. - unique_id = f"{home_id}.20-113-0-Access Control-Door state.22" - entity_entry = entity_registry.async_get_or_create( - BINARY_SENSOR_DOMAIN, - DOMAIN, - unique_id, - suggested_object_id="ehandle_connectsense_window_door_is_open", - original_name="Window/door is open", - ) - entity_id = entity_entry.entity_id - - # Seed a stale repair issue as if it had been created in a previous run. - async_create_issue( - hass, - DOMAIN, - f"deprecated_legacy_door_state.{entity_id}", - is_fixable=False, - is_persistent=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_legacy_door_state", - translation_placeholders={ - "entity_id": entity_id, - "entity_name": "Window/door is open", - "opening_state_entity_id": "sensor.ehandle_connectsense_opening_state", - "items": "- [test](/config/automation/edit/test_automation)", - }, - ) - assert ( - issue_registry.async_get_issue( - DOMAIN, f"deprecated_legacy_door_state.{entity_id}" - ) - is not None - ) - - # Load the integration with no automation referencing the legacy entity. - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Stale issue should have been cleaned up. - assert ( - issue_registry.async_get_issue( - DOMAIN, f"deprecated_legacy_door_state.{entity_id}" - ) - is None - ) From 6dd5c30b496de71ab82c3f43f2f5508342ddbb87 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Mar 2026 10:59:11 +0000 Subject: [PATCH 029/138] Bump version to 2026.4.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7341f499355..b285564c8f7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index c2d8c7ab971..ca17ff3f791 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0b1" +version = "2026.4.0b2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 2fa16101f4745a6faae265f9ec867ca1f9bea5a8 Mon Sep 17 00:00:00 2001 From: John Meyers Date: Thu, 26 Mar 2026 09:11:12 -0400 Subject: [PATCH 030/138] =?UTF-8?q?Update=20rainmachine=20solar=20radiatio?= =?UTF-8?q?n=20to=20reflect=20it=20is=20per=20day,=20not=20per=20=E2=80=A6?= =?UTF-8?q?=20(#166040)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/rainmachine/__init__.py | 2 +- homeassistant/components/rainmachine/services.yaml | 4 ++-- homeassistant/components/rainmachine/strings.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 2727e877bfe..c4fe2b49006 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -102,7 +102,7 @@ CV_WX_DATA_VALID_TEMP_RANGE = vol.All(vol.Coerce(float), vol.Range(min=-40.0, ma CV_WX_DATA_VALID_RAIN_RANGE = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1000.0)) CV_WX_DATA_VALID_WIND_SPEED = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=65.0)) CV_WX_DATA_VALID_PRESSURE = vol.All(vol.Coerce(float), vol.Range(min=60.0, max=110.0)) -CV_WX_DATA_VALID_SOLARRAD = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=5.0)) +CV_WX_DATA_VALID_SOLARRAD = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)) SERVICE_NAME_PAUSE_WATERING = "pause_watering" SERVICE_NAME_PUSH_FLOW_METER_DATA = "push_flow_meter_data" diff --git a/homeassistant/components/rainmachine/services.yaml b/homeassistant/components/rainmachine/services.yaml index 2f799afd028..2c548e3947a 100644 --- a/homeassistant/components/rainmachine/services.yaml +++ b/homeassistant/components/rainmachine/services.yaml @@ -131,9 +131,9 @@ push_weather_data: selector: number: min: 0 - max: 5 + max: 100 step: 0.1 - unit_of_measurement: "MJ/m²/h" + unit_of_measurement: "MJ/m²/d" et: selector: number: diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index 778d8ebb16f..df9df38d4f4 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -175,7 +175,7 @@ "name": "Measured rainfall" }, "solarrad": { - "description": "Current solar radiation (MJ/m²/h).", + "description": "Daily solar radiation (MJ/m²/d).", "name": "Solar radiation" }, "temperature": { From cb5b0c5b5e63d01907fa29c43a1800dcb00e8cc2 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 26 Mar 2026 17:21:30 +0100 Subject: [PATCH 031/138] Verify Proxmox permissions when creating snapshots (#166547) --- homeassistant/components/proxmoxve/button.py | 39 +++++++++++++++---- homeassistant/components/proxmoxve/const.py | 9 ++++- homeassistant/components/proxmoxve/helpers.py | 4 +- .../components/proxmoxve/strings.json | 3 ++ tests/components/proxmoxve/__init__.py | 15 ++++++- tests/components/proxmoxve/test_button.py | 15 ++++++- 6 files changed, 71 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index 6229d5ae671..833600d8ebd 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN +from .const import DOMAIN, ProxmoxPermission from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity from .helpers import is_granted @@ -34,6 +34,8 @@ class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): """Class to hold Proxmox node button description.""" press_action: Callable[[ProxmoxCoordinator, str], None] + permission: ProxmoxPermission = ProxmoxPermission.POWER + permission_raise: str = "no_permission_node_power" @dataclass(frozen=True, kw_only=True) @@ -41,6 +43,8 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription): """Class to hold Proxmox VM button description.""" press_action: Callable[[ProxmoxCoordinator, str, int], None] + permission: ProxmoxPermission = ProxmoxPermission.POWER + permission_raise: str = "no_permission_vm_lxc_power" @dataclass(frozen=True, kw_only=True) @@ -48,6 +52,8 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): """Class to hold Proxmox container button description.""" press_action: Callable[[ProxmoxCoordinator, str, int], None] + permission: ProxmoxPermission = ProxmoxPermission.POWER + permission_raise: str = "no_permission_vm_lxc_power" NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( @@ -156,6 +162,8 @@ VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = ( ) ) ), + permission=ProxmoxPermission.SNAPSHOT, + permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -199,6 +207,8 @@ CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = ( ) ) ), + permission=ProxmoxPermission.SNAPSHOT, + permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -315,10 +325,15 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton): async def _async_press_call(self) -> None: """Execute the node button action via executor.""" node_id = self._node_data.node["node"] - if not is_granted(self.coordinator.permissions, p_type="nodes", p_id=node_id): + if not is_granted( + self.coordinator.permissions, + p_type="nodes", + p_id=node_id, + permission=self.entity_description.permission, + ): raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="no_permission_node_power", + translation_key=self.entity_description.permission_raise, ) await self.hass.async_add_executor_job( self.entity_description.press_action, @@ -335,10 +350,15 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton): async def _async_press_call(self) -> None: """Execute the VM button action via executor.""" vmid = self.vm_data["vmid"] - if not is_granted(self.coordinator.permissions, p_type="vms", p_id=vmid): + if not is_granted( + self.coordinator.permissions, + p_type="vms", + p_id=vmid, + permission=self.entity_description.permission, + ): raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="no_permission_vm_lxc_power", + translation_key=self.entity_description.permission_raise, ) await self.hass.async_add_executor_job( self.entity_description.press_action, @@ -357,10 +377,15 @@ class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton): """Execute the container button action via executor.""" vmid = self.container_data["vmid"] # Container power actions fall under vms - if not is_granted(self.coordinator.permissions, p_type="vms", p_id=vmid): + if not is_granted( + self.coordinator.permissions, + p_type="vms", + p_id=vmid, + permission=self.entity_description.permission, + ): raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="no_permission_vm_lxc_power", + translation_key=self.entity_description.permission_raise, ) await self.hass.async_add_executor_job( self.entity_description.press_action, diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index ad0a0ebda69..4cf821446c1 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -1,5 +1,7 @@ """Constants for ProxmoxVE.""" +from enum import StrEnum + DOMAIN = "proxmoxve" CONF_AUTH_METHOD = "auth_method" CONF_REALM = "realm" @@ -33,4 +35,9 @@ TYPE_VM = 0 TYPE_CONTAINER = 1 UPDATE_INTERVAL = 60 -PERM_POWER = "VM.PowerMgmt" + +class ProxmoxPermission(StrEnum): + """Proxmox permissions.""" + + POWER = "VM.PowerMgmt" + SNAPSHOT = "VM.Snapshot" diff --git a/homeassistant/components/proxmoxve/helpers.py b/homeassistant/components/proxmoxve/helpers.py index 0096170a954..c7e96bcd300 100644 --- a/homeassistant/components/proxmoxve/helpers.py +++ b/homeassistant/components/proxmoxve/helpers.py @@ -1,13 +1,13 @@ """Helpers for Proxmox VE.""" -from .const import PERM_POWER +from .const import ProxmoxPermission def is_granted( permissions: dict[str, dict[str, int]], p_type: str = "vms", p_id: str | int | None = None, # can be str for nodes - permission: str = PERM_POWER, + permission: ProxmoxPermission = ProxmoxPermission.POWER, ) -> bool: """Validate user permissions for the given type and permission.""" paths = [f"/{p_type}/{p_id}", f"/{p_type}", "/"] diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index fcb13b68a93..12ee765d9f2 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -315,6 +315,9 @@ "no_permission_node_power": { "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'VM.PowerMgmt' permission and try again." }, + "no_permission_snapshot": { + "message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again." + }, "no_permission_vm_lxc_power": { "message": "The configured Proxmox VE user does not have permission to manage the power state of VMs and containers. Please grant the user the 'VM.PowerMgmt' permission and try again." }, diff --git a/tests/components/proxmoxve/__init__.py b/tests/components/proxmoxve/__init__.py index 3241c45ac52..e8c596b77dc 100644 --- a/tests/components/proxmoxve/__init__.py +++ b/tests/components/proxmoxve/__init__.py @@ -31,9 +31,20 @@ POWER_PERMISSIONS = { "/vms/101": {"VM.PowerMgmt": 0}, } +SNAPSHOT_PERMISSIONS = { + "/vms": {"VM.Snapshot": 1}, + "/vms/101": {"VM.Snapshot": 0}, +} + MERGED_PERMISSIONS = { - key: {**AUDIT_PERMISSIONS.get(key, {}), **POWER_PERMISSIONS.get(key, {})} - for key in set(AUDIT_PERMISSIONS) | set(POWER_PERMISSIONS) + key: { + **AUDIT_PERMISSIONS.get(key, {}), + **POWER_PERMISSIONS.get(key, {}), + **SNAPSHOT_PERMISSIONS.get(key, {}), + } + for key in set(AUDIT_PERMISSIONS) + | set(POWER_PERMISSIONS) + | set(SNAPSHOT_PERMISSIONS) } diff --git a/tests/components/proxmoxve/test_button.py b/tests/components/proxmoxve/test_button.py index 9ca1b13ff8e..fb2c5a88508 100644 --- a/tests/components/proxmoxve/test_button.py +++ b/tests/components/proxmoxve/test_button.py @@ -370,6 +370,7 @@ async def test_container_buttons_exceptions( ("button.pve1_start_all", "no_permission_node_power"), ("button.ct_nginx_start", "no_permission_vm_lxc_power"), ("button.vm_web_start", "no_permission_vm_lxc_power"), + ("button.vm_web_create_snapshot", "no_permission_snapshot"), ], ) async def test_node_buttons_permission_denied_for_auditor_role( @@ -394,19 +395,29 @@ async def test_node_buttons_permission_denied_for_auditor_role( assert exc_info.value.translation_key == translation_key +@pytest.mark.parametrize( + ("entity_id", "translation_key"), + [ + ("button.vm_db_start", "no_permission_vm_lxc_power"), + ("button.vm_db_create_snapshot", "no_permission_snapshot"), + ], +) async def test_vm_buttons_denied_for_specific_vm( hass: HomeAssistant, mock_proxmox_client: MagicMock, mock_config_entry: MockConfigEntry, + entity_id: str, + translation_key: str, ) -> None: """Test that button only works on actual permissions.""" await setup_integration(hass, mock_config_entry) mock_proxmox_client._node_mock.qemu(101) - with pytest.raises(ServiceValidationError): + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.vm_db_start"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + assert exc_info.value.translation_key == translation_key From 87cd90ab5d853078c0c2a1da093c8a1f0d75cc97 Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Thu, 26 Mar 2026 08:15:04 -0500 Subject: [PATCH 032/138] Bump lojack-api to 0.7.2 (#166560) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/lojack/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lojack/manifest.json b/homeassistant/components/lojack/manifest.json index fa2e0fec450..af0a0cb6afa 100644 --- a/homeassistant/components/lojack/manifest.json +++ b/homeassistant/components/lojack/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["lojack_api"], "quality_scale": "silver", - "requirements": ["lojack-api==0.7.1"] + "requirements": ["lojack-api==0.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9fa559c5d34..b919a771c9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1452,7 +1452,7 @@ livisi==0.0.25 locationsharinglib==5.0.1 # homeassistant.components.lojack -lojack-api==0.7.1 +lojack-api==0.7.2 # homeassistant.components.london_underground london-tube-status==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd19dff5a5b..c7b30728378 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1274,7 +1274,7 @@ libsoundtouch==0.8 livisi==0.0.25 # homeassistant.components.lojack -lojack-api==0.7.1 +lojack-api==0.7.2 # homeassistant.components.london_underground london-tube-status==0.5 From d8a32dcf69b77adeb9766c2b92d4ef5ffcd76fd6 Mon Sep 17 00:00:00 2001 From: hanwg Date: Thu, 26 Mar 2026 23:03:21 +0800 Subject: [PATCH 033/138] Add missing translations for Telegram bot (#166581) Co-authored-by: Robert Resch --- homeassistant/components/telegram_bot/config_flow.py | 2 +- homeassistant/components/telegram_bot/services.yaml | 9 +-------- homeassistant/components/telegram_bot/strings.json | 12 ++++++++++++ 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index e501ae337ed..c2d6ed368ed 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -188,7 +188,7 @@ class OptionsFlowHandler(OptionsFlow): ) -class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): +class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Telegram.""" VERSION = 1 diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index b764ba31a06..d3bb993376f 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -225,9 +225,9 @@ send_media_group: multiple: true label_field: url description_field: caption + translation_key: "media" fields: media_type: - label: Media type selector: select: options: @@ -237,20 +237,16 @@ send_media_group: - "video" translation_key: "media_type" caption: - label: Caption selector: text: url: - label: URL selector: text: type: url verify_ssl: - label: Verify SSL selector: boolean: authentication: - label: Authentication selector: select: options: @@ -259,16 +255,13 @@ send_media_group: - "bearer_token" translation_key: "authentication" username: - label: Username selector: text: password: - label: Password selector: text: type: password file: - label: File selector: text: parse_mode: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 42e94054fb4..c332484911c 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -279,6 +279,18 @@ "upload_voice": "Uploading voice" } }, + "media": { + "fields": { + "authentication": "Authentication", + "caption": "Caption", + "file": "File", + "media_type": "Media type", + "password": "Password", + "url": "URL", + "username": "Username", + "verify_ssl": "Verify SSL" + } + }, "media_type": { "options": { "animation": "Animation", From bd5c73fd7bed66716cc5873b2acc5a38a0a53ba0 Mon Sep 17 00:00:00 2001 From: reneboer Date: Thu, 26 Mar 2026 13:16:50 +0100 Subject: [PATCH 034/138] Bump renault-api to 0.5.7 (#166586) --- homeassistant/components/renault/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 8498001de7b..a2f907aaf64 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.5.6"] + "requirements": ["renault-api==0.5.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index b919a771c9c..ed530439b31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2823,7 +2823,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.5.6 +renault-api==0.5.7 # homeassistant.components.renson renson-endura-delta==1.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7b30728378..829bcbfb963 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2401,7 +2401,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.5.6 +renault-api==0.5.7 # homeassistant.components.renson renson-endura-delta==1.7.2 From 58722222137092d9625df9e1088e35c726b9d52e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 13:13:07 +0100 Subject: [PATCH 035/138] Remove class NumericalDomainSpec (#166588) --- .../components/air_quality/condition.py | 44 +++------- .../components/air_quality/trigger.py | 86 ++++++------------- homeassistant/components/battery/trigger.py | 8 +- homeassistant/components/climate/condition.py | 6 +- homeassistant/components/climate/trigger.py | 8 +- homeassistant/components/cover/condition.py | 6 +- homeassistant/components/cover/trigger.py | 6 +- .../components/humidifier/condition.py | 4 +- .../components/humidity/condition.py | 6 +- homeassistant/components/humidity/trigger.py | 12 +-- .../components/illuminance/trigger.py | 8 +- homeassistant/components/light/trigger.py | 58 ++++++++----- homeassistant/components/moisture/trigger.py | 8 +- homeassistant/components/power/condition.py | 6 +- homeassistant/components/power/trigger.py | 8 +- .../components/temperature/condition.py | 10 +-- .../components/temperature/trigger.py | 12 ++- .../components/water_heater/condition.py | 4 +- .../components/water_heater/trigger.py | 4 +- homeassistant/helpers/automation.py | 10 +-- homeassistant/helpers/condition.py | 4 +- homeassistant/helpers/trigger.py | 26 ++---- tests/helpers/test_condition.py | 5 +- tests/helpers/test_trigger.py | 33 ++----- 24 files changed, 159 insertions(+), 223 deletions(-) diff --git a/homeassistant/components/air_quality/condition.py b/homeassistant/components/air_quality/condition.py index 0e11ce9016b..0a8ad107daa 100644 --- a/homeassistant/components/air_quality/condition.py +++ b/homeassistant/components/air_quality/condition.py @@ -13,7 +13,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( Condition, make_entity_numerical_condition, @@ -59,18 +59,18 @@ CONDITIONS: dict[str, type[Condition]] = { "is_smoke_cleared": _make_cleared_condition(BinarySensorDeviceClass.SMOKE), # Numerical sensor conditions with unit conversion "is_co_value": make_entity_numerical_condition_with_unit( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CarbonMonoxideConcentrationConverter, ), "is_ozone_value": make_entity_numerical_condition_with_unit( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, OzoneConcentrationConverter, ), "is_voc_value": make_entity_numerical_condition_with_unit( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS ) }, @@ -79,7 +79,7 @@ CONDITIONS: dict[str, type[Condition]] = { ), "is_voc_ratio_value": make_entity_numerical_condition_with_unit( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS ) }, @@ -87,59 +87,43 @@ CONDITIONS: dict[str, type[Condition]] = { UnitlessRatioConverter, ), "is_no_value": make_entity_numerical_condition_with_unit( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_MONOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenMonoxideConcentrationConverter, ), "is_no2_value": make_entity_numerical_condition_with_unit( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenDioxideConcentrationConverter, ), "is_so2_value": make_entity_numerical_condition_with_unit( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.SULPHUR_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SulphurDioxideConcentrationConverter, ), # Numerical sensor conditions without unit conversion (single-unit device classes) "is_co2_value": make_entity_numerical_condition( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)}, valid_unit=CONCENTRATION_PARTS_PER_MILLION, ), "is_pm1_value": make_entity_numerical_condition( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "is_pm25_value": make_entity_numerical_condition( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "is_pm4_value": make_entity_numerical_condition( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "is_pm10_value": make_entity_numerical_condition( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "is_n2o_value": make_entity_numerical_condition( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROUS_OXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), } diff --git a/homeassistant/components/air_quality/trigger.py b/homeassistant/components/air_quality/trigger.py index abf65300424..6e42b55e905 100644 --- a/homeassistant/components/air_quality/trigger.py +++ b/homeassistant/components/air_quality/trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( EntityTargetStateTriggerBase, Trigger, @@ -64,28 +64,28 @@ TRIGGERS: dict[str, type[Trigger]] = { "smoke_cleared": _make_cleared_trigger(BinarySensorDeviceClass.SMOKE), # Numerical sensor triggers with unit conversion "co_changed": make_entity_numerical_state_changed_with_unit_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CarbonMonoxideConcentrationConverter, ), "co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CarbonMonoxideConcentrationConverter, ), "ozone_changed": make_entity_numerical_state_changed_with_unit_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, OzoneConcentrationConverter, ), "ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, OzoneConcentrationConverter, ), "voc_changed": make_entity_numerical_state_changed_with_unit_trigger( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS ) }, @@ -94,7 +94,7 @@ TRIGGERS: dict[str, type[Trigger]] = { ), "voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS ) }, @@ -103,7 +103,7 @@ TRIGGERS: dict[str, type[Trigger]] = { ), "voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS ) }, @@ -112,7 +112,7 @@ TRIGGERS: dict[str, type[Trigger]] = { ), "voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS ) }, @@ -120,114 +120,82 @@ TRIGGERS: dict[str, type[Trigger]] = { UnitlessRatioConverter, ), "no_changed": make_entity_numerical_state_changed_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_MONOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenMonoxideConcentrationConverter, ), "no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_MONOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenMonoxideConcentrationConverter, ), "no2_changed": make_entity_numerical_state_changed_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenDioxideConcentrationConverter, ), "no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenDioxideConcentrationConverter, ), "so2_changed": make_entity_numerical_state_changed_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.SULPHUR_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SulphurDioxideConcentrationConverter, ), "so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.SULPHUR_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SulphurDioxideConcentrationConverter, ), # Numerical sensor triggers without unit conversion (single-unit device classes) "co2_changed": make_entity_numerical_state_changed_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)}, valid_unit=CONCENTRATION_PARTS_PER_MILLION, ), "co2_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)}, valid_unit=CONCENTRATION_PARTS_PER_MILLION, ), "pm1_changed": make_entity_numerical_state_changed_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm1_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm25_changed": make_entity_numerical_state_changed_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm25_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm4_changed": make_entity_numerical_state_changed_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm4_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm10_changed": make_entity_numerical_state_changed_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm10_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "n2o_changed": make_entity_numerical_state_changed_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROUS_OXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "n2o_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROUS_OXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), } diff --git a/homeassistant/components/battery/trigger.py b/homeassistant/components/battery/trigger.py index ff4d681c5d3..2547b70cd7d 100644 --- a/homeassistant/components/battery/trigger.py +++ b/homeassistant/components/battery/trigger.py @@ -10,7 +10,7 @@ from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDevic from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( Trigger, make_entity_numerical_state_changed_trigger, @@ -28,9 +28,9 @@ BATTERY_CHARGING_DOMAIN_SPECS: dict[str, DomainSpec] = { ), } -BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { - SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.BATTERY), - NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.BATTERY), +BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = { + SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY), + NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.BATTERY), } TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py index 4f3c4bf1f6c..8279b9bf583 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -2,7 +2,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( Condition, EntityNumericalConditionWithUnitBase, @@ -18,7 +18,7 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase): """Mixin for climate target temperature conditions with unit conversion.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter def _get_entity_unit(self, entity_state: State) -> str | None: @@ -50,7 +50,7 @@ CONDITIONS: dict[str, type[Condition]] = { {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING ), "target_humidity": make_entity_numerical_condition( - {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}, + {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, valid_unit="%", ), "target_temperature": ClimateTargetTemperatureCondition, diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index 4faf4a1a4df..9f9f02d7071 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, EntityNumericalStateChangedTriggerWithUnitBase, @@ -52,7 +52,7 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB """Mixin for climate target temperature triggers with unit conversion.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter def _get_entity_unit(self, state: State) -> str | None: @@ -84,11 +84,11 @@ TRIGGERS: dict[str, type[Trigger]] = { {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING ), "target_humidity_changed": make_entity_numerical_state_changed_trigger( - {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}, + {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, valid_unit="%", ), "target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}, + {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, valid_unit="%", ), "target_temperature_changed": ClimateTargetTemperatureChangedTrigger, diff --git a/homeassistant/components/cover/condition.py b/homeassistant/components/cover/condition.py index 7092c021c56..f44ad6582cb 100644 --- a/homeassistant/components/cover/condition.py +++ b/homeassistant/components/cover/condition.py @@ -1,5 +1,7 @@ """Provides conditions for covers.""" +from collections.abc import Mapping + from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State from homeassistant.helpers.condition import Condition, EntityConditionBase @@ -8,9 +10,11 @@ from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass from .models import CoverDomainSpec -class CoverConditionBase(EntityConditionBase[CoverDomainSpec]): +class CoverConditionBase(EntityConditionBase): """Base condition for cover state checks.""" + _domain_specs: Mapping[str, CoverDomainSpec] + def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected cover state.""" domain_spec = self._domain_specs[entity_state.domain] diff --git a/homeassistant/components/cover/trigger.py b/homeassistant/components/cover/trigger.py index 149a3e01cc0..1d3ceba1177 100644 --- a/homeassistant/components/cover/trigger.py +++ b/homeassistant/components/cover/trigger.py @@ -1,5 +1,7 @@ """Provides triggers for covers.""" +from collections.abc import Mapping + from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.helpers.trigger import EntityTriggerBase, Trigger @@ -8,9 +10,11 @@ from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass from .models import CoverDomainSpec -class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]): +class CoverTriggerBase(EntityTriggerBase): """Base trigger for cover state changes.""" + _domain_specs: Mapping[str, CoverDomainSpec] + def _get_value(self, state: State) -> str | bool | None: """Extract the relevant value from state based on domain spec.""" domain_spec = self._domain_specs[state.domain] diff --git a/homeassistant/components/humidifier/condition.py b/homeassistant/components/humidifier/condition.py index a787bd3d1f5..0795291ae97 100644 --- a/homeassistant/components/humidifier/condition.py +++ b/homeassistant/components/humidifier/condition.py @@ -2,7 +2,7 @@ from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( Condition, make_entity_numerical_condition, @@ -21,7 +21,7 @@ CONDITIONS: dict[str, type[Condition]] = { {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING ), "is_target_humidity": make_entity_numerical_condition( - {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}, + {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, valid_unit=PERCENTAGE, ), } diff --git a/homeassistant/components/humidity/condition.py b/homeassistant/components/humidity/condition.py index a41fd8ab4e4..818f649e2c2 100644 --- a/homeassistant/components/humidity/condition.py +++ b/homeassistant/components/humidity/condition.py @@ -14,14 +14,14 @@ from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDevic from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import Condition, make_entity_numerical_condition HUMIDITY_DOMAIN_SPECS = { - CLIMATE_DOMAIN: NumericalDomainSpec( + CLIMATE_DOMAIN: DomainSpec( value_source=CLIMATE_ATTR_CURRENT_HUMIDITY, ), - HUMIDIFIER_DOMAIN: NumericalDomainSpec( + HUMIDIFIER_DOMAIN: DomainSpec( value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.HUMIDITY), diff --git a/homeassistant/components/humidity/trigger.py b/homeassistant/components/humidity/trigger.py index 9729543b450..53347675045 100644 --- a/homeassistant/components/humidity/trigger.py +++ b/homeassistant/components/humidity/trigger.py @@ -16,24 +16,24 @@ from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( Trigger, make_entity_numerical_state_changed_trigger, make_entity_numerical_state_crossed_threshold_trigger, ) -HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { - CLIMATE_DOMAIN: NumericalDomainSpec( +HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = { + CLIMATE_DOMAIN: DomainSpec( value_source=CLIMATE_ATTR_CURRENT_HUMIDITY, ), - HUMIDIFIER_DOMAIN: NumericalDomainSpec( + HUMIDIFIER_DOMAIN: DomainSpec( value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ), - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.HUMIDITY, ), - WEATHER_DOMAIN: NumericalDomainSpec( + WEATHER_DOMAIN: DomainSpec( value_source=ATTR_WEATHER_HUMIDITY, ), } diff --git a/homeassistant/components/illuminance/trigger.py b/homeassistant/components/illuminance/trigger.py index c6511980b91..042a207beb6 100644 --- a/homeassistant/components/illuminance/trigger.py +++ b/homeassistant/components/illuminance/trigger.py @@ -10,7 +10,7 @@ from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDevic from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( Trigger, make_entity_numerical_state_changed_trigger, @@ -18,9 +18,9 @@ from homeassistant.helpers.trigger import ( make_entity_target_state_trigger, ) -ILLUMINANCE_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { - NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.ILLUMINANCE), - SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.ILLUMINANCE), +ILLUMINANCE_DOMAIN_SPECS: dict[str, DomainSpec] = { + NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.ILLUMINANCE), + SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.ILLUMINANCE), } TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/light/trigger.py b/homeassistant/components/light/trigger.py index 82570a16803..25bd6fbaeee 100644 --- a/homeassistant/components/light/trigger.py +++ b/homeassistant/components/light/trigger.py @@ -1,40 +1,54 @@ """Provides triggers for lights.""" -from typing import Any - from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( + EntityNumericalStateChangedTriggerBase, + EntityNumericalStateCrossedThresholdTriggerBase, + EntityNumericalStateTriggerBase, Trigger, - make_entity_numerical_state_changed_trigger, - make_entity_numerical_state_crossed_threshold_trigger, make_entity_target_state_trigger, ) from . import ATTR_BRIGHTNESS from .const import DOMAIN - -def _convert_uint8_to_percentage(value: Any) -> float: - """Convert a uint8 value (0-255) to a percentage (0-100).""" - return (float(value) / 255.0) * 100.0 - - BRIGHTNESS_DOMAIN_SPECS = { - DOMAIN: NumericalDomainSpec( - value_source=ATTR_BRIGHTNESS, - value_converter=_convert_uint8_to_percentage, - ), + DOMAIN: DomainSpec(value_source=ATTR_BRIGHTNESS), } + +class BrightnessTriggerMixin(EntityNumericalStateTriggerBase): + """Mixin for brightness triggers.""" + + _domain_specs = BRIGHTNESS_DOMAIN_SPECS + _valid_unit = "%" + + def _get_tracked_value(self, state: State) -> float | None: + """Get tracked brightness as a percentage.""" + value = super()._get_tracked_value(state) + if value is None: + return None + # Convert uint8 value (0-255) to a percentage (0-100) + return (value / 255.0) * 100.0 + + +class BrightnessChangedTrigger( + EntityNumericalStateChangedTriggerBase, BrightnessTriggerMixin +): + """Trigger for light brightness changes.""" + + +class BrightnessCrossedThresholdTrigger( + EntityNumericalStateCrossedThresholdTriggerBase, BrightnessTriggerMixin +): + """Trigger for light brightness crossing a threshold.""" + + TRIGGERS: dict[str, type[Trigger]] = { - "brightness_changed": make_entity_numerical_state_changed_trigger( - BRIGHTNESS_DOMAIN_SPECS, valid_unit="%" - ), - "brightness_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - BRIGHTNESS_DOMAIN_SPECS, valid_unit="%" - ), + "brightness_changed": BrightnessChangedTrigger, + "brightness_crossed_threshold": BrightnessCrossedThresholdTrigger, "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), } diff --git a/homeassistant/components/moisture/trigger.py b/homeassistant/components/moisture/trigger.py index 07b20b50b1e..6c50a83a952 100644 --- a/homeassistant/components/moisture/trigger.py +++ b/homeassistant/components/moisture/trigger.py @@ -10,7 +10,7 @@ from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDevic from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( Trigger, make_entity_numerical_state_changed_trigger, @@ -22,9 +22,9 @@ MOISTURE_BINARY_DOMAIN_SPECS: dict[str, DomainSpec] = { BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOISTURE), } -MOISTURE_NUMERICAL_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { - NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.MOISTURE), - SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.MOISTURE), +MOISTURE_NUMERICAL_DOMAIN_SPECS: dict[str, DomainSpec] = { + NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.MOISTURE), + SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.MOISTURE), } diff --git a/homeassistant/components/power/condition.py b/homeassistant/components/power/condition.py index 1b3ac519a12..78a8ee98ae0 100644 --- a/homeassistant/components/power/condition.py +++ b/homeassistant/components/power/condition.py @@ -6,7 +6,7 @@ from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDevic 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.automation import DomainSpec from homeassistant.helpers.condition import ( Condition, make_entity_numerical_condition_with_unit, @@ -14,8 +14,8 @@ from homeassistant.helpers.condition import ( from homeassistant.util.unit_conversion import PowerConverter POWER_DOMAIN_SPECS = { - NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.POWER), - SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.POWER), + NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.POWER), + SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER), } diff --git a/homeassistant/components/power/trigger.py b/homeassistant/components/power/trigger.py index 6a2d3d8b1d8..b1e8274527f 100644 --- a/homeassistant/components/power/trigger.py +++ b/homeassistant/components/power/trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDevic 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.automation import DomainSpec from homeassistant.helpers.trigger import ( Trigger, make_entity_numerical_state_changed_with_unit_trigger, @@ -14,9 +14,9 @@ from homeassistant.helpers.trigger import ( ) 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), +POWER_DOMAIN_SPECS: dict[str, DomainSpec] = { + NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.POWER), + SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER), } diff --git a/homeassistant/components/temperature/condition.py b/homeassistant/components/temperature/condition.py index e8cf3150be4..3bae43cc03b 100644 --- a/homeassistant/components/temperature/condition.py +++ b/homeassistant/components/temperature/condition.py @@ -18,7 +18,7 @@ from homeassistant.components.weather import ( ) from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( Condition, EntityNumericalConditionWithUnitBase, @@ -26,16 +26,16 @@ from homeassistant.helpers.condition import ( from homeassistant.util.unit_conversion import TemperatureConverter TEMPERATURE_DOMAIN_SPECS = { - CLIMATE_DOMAIN: NumericalDomainSpec( + CLIMATE_DOMAIN: DomainSpec( value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE, ), - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.TEMPERATURE, ), - WATER_HEATER_DOMAIN: NumericalDomainSpec( + WATER_HEATER_DOMAIN: DomainSpec( value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE, ), - WEATHER_DOMAIN: NumericalDomainSpec( + WEATHER_DOMAIN: DomainSpec( value_source=ATTR_WEATHER_TEMPERATURE, ), } diff --git a/homeassistant/components/temperature/trigger.py b/homeassistant/components/temperature/trigger.py index c255d39d129..79995349e66 100644 --- a/homeassistant/components/temperature/trigger.py +++ b/homeassistant/components/temperature/trigger.py @@ -18,7 +18,7 @@ from homeassistant.components.weather import ( ) from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( EntityNumericalStateChangedTriggerWithUnitBase, EntityNumericalStateCrossedThresholdTriggerWithUnitBase, @@ -28,16 +28,14 @@ from homeassistant.helpers.trigger import ( from homeassistant.util.unit_conversion import TemperatureConverter TEMPERATURE_DOMAIN_SPECS = { - CLIMATE_DOMAIN: NumericalDomainSpec( + CLIMATE_DOMAIN: DomainSpec( value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE, ), - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.TEMPERATURE, ), - WATER_HEATER_DOMAIN: NumericalDomainSpec( - value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE - ), - WEATHER_DOMAIN: NumericalDomainSpec( + WATER_HEATER_DOMAIN: DomainSpec(value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE), + WEATHER_DOMAIN: DomainSpec( value_source=ATTR_WEATHER_TEMPERATURE, ), } diff --git a/homeassistant/components/water_heater/condition.py b/homeassistant/components/water_heater/condition.py index ce1f36c5269..da9b8a383d9 100644 --- a/homeassistant/components/water_heater/condition.py +++ b/homeassistant/components/water_heater/condition.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, @@ -73,7 +73,7 @@ class WaterHeaterTargetTemperatureCondition(EntityNumericalConditionWithUnitBase """Condition for water heater target temperature.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter def _get_entity_unit(self, entity_state: State) -> str | None: diff --git a/homeassistant/components/water_heater/trigger.py b/homeassistant/components/water_heater/trigger.py index 786f5b75016..0a434b498b5 100644 --- a/homeassistant/components/water_heater/trigger.py +++ b/homeassistant/components/water_heater/trigger.py @@ -10,7 +10,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, EntityNumericalStateChangedTriggerWithUnitBase, @@ -57,7 +57,7 @@ class _WaterHeaterTargetTemperatureTriggerMixin( """Mixin for water heater target temperature triggers with unit conversion.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter def _get_entity_unit(self, state: State) -> str | None: diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py index 83f827ad75e..80c3da754cc 100644 --- a/homeassistant/helpers/automation.py +++ b/homeassistant/helpers/automation.py @@ -1,6 +1,6 @@ """Helpers for automation.""" -from collections.abc import Callable, Mapping +from collections.abc import Mapping from dataclasses import dataclass from enum import Enum from typing import Any, Final, Self @@ -37,14 +37,6 @@ class DomainSpec: """Attribute name to extract the value from, or None for state.state.""" -@dataclass(frozen=True, slots=True) -class NumericalDomainSpec(DomainSpec): - """DomainSpec with an optional value converter for numerical triggers.""" - - value_converter: Callable[[float], float] | None = None - """Optional converter for numerical values (e.g. uint8 → percentage).""" - - def filter_by_domain_specs( hass: HomeAssistant, domain_specs: Mapping[str, DomainSpec], diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 19537a20f0b..e71dc1b991b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -342,10 +342,10 @@ ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL = vol.Schema( ) -class EntityConditionBase[DomainSpecT: DomainSpec = DomainSpec](Condition): +class EntityConditionBase(Condition): """Base class for entity conditions.""" - _domain_specs: Mapping[str, DomainSpecT] + _domain_specs: Mapping[str, DomainSpec] _schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL @override diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 975c3dddc3c..404051fd5fc 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -68,7 +68,6 @@ from homeassistant.util.yaml import load_yaml_dict from . import config_validation as cv, selector from .automation import ( DomainSpec, - NumericalDomainSpec, ThresholdConfig, filter_by_domain_specs, get_absolute_description_key, @@ -340,10 +339,10 @@ ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST = ENTITY_STATE_TRIGGER_SCHEMA.extend( ) -class EntityTriggerBase[DomainSpecT: DomainSpec = DomainSpec](Trigger): +class EntityTriggerBase(Trigger): """Trigger for entity state changes.""" - _domain_specs: Mapping[str, DomainSpecT] + _domain_specs: Mapping[str, DomainSpec] _schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST @override @@ -534,7 +533,7 @@ NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( ) -class EntityNumericalStateTriggerBase(EntityTriggerBase[NumericalDomainSpec]): +class EntityNumericalStateTriggerBase(EntityTriggerBase): """Base class for numerical state and state attribute triggers.""" _valid_unit: str | None | UndefinedType = UNDEFINED @@ -595,21 +594,12 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase[NumericalDomainSpec]): # Entity state is not a valid number return None - def _get_converter(self, state: State) -> Callable[[float], float]: - """Get the value converter for an entity.""" - domain_spec = self._domain_specs[state.domain] - if domain_spec.value_converter is not None: - return domain_spec.value_converter - return lambda x: x - def is_valid_state(self, state: State) -> bool: """Check if the new state or state attribute matches the expected one.""" # Handle missing or None value case first to avoid expensive exceptions - if (_attribute_value := self._get_tracked_value(state)) is None: + if (current_value := self._get_tracked_value(state)) is None: return False - current_value = self._get_converter(state)(_attribute_value) - if self._threshold_type == NumericThresholdType.ANY: # If the threshold type is "any" we always trigger on valid state # changes @@ -890,7 +880,7 @@ def make_entity_origin_state_trigger( def make_entity_numerical_state_changed_trigger( - domain_specs: Mapping[str, NumericalDomainSpec], + domain_specs: Mapping[str, DomainSpec], valid_unit: str | None | UndefinedType = UNDEFINED, ) -> type[EntityNumericalStateChangedTriggerBase]: """Create a trigger for numerical state value change.""" @@ -905,7 +895,7 @@ def make_entity_numerical_state_changed_trigger( def make_entity_numerical_state_crossed_threshold_trigger( - domain_specs: Mapping[str, NumericalDomainSpec], + domain_specs: Mapping[str, DomainSpec], valid_unit: str | None | UndefinedType = UNDEFINED, ) -> type[EntityNumericalStateCrossedThresholdTriggerBase]: """Create a trigger for numerical state value crossing a threshold.""" @@ -920,7 +910,7 @@ def make_entity_numerical_state_crossed_threshold_trigger( def make_entity_numerical_state_changed_with_unit_trigger( - domain_specs: Mapping[str, NumericalDomainSpec], + domain_specs: Mapping[str, DomainSpec], base_unit: str, unit_converter: type[BaseUnitConverter], ) -> type[EntityNumericalStateChangedTriggerWithUnitBase]: @@ -937,7 +927,7 @@ def make_entity_numerical_state_changed_with_unit_trigger( def make_entity_numerical_state_crossed_threshold_with_unit_trigger( - domain_specs: Mapping[str, NumericalDomainSpec], + domain_specs: Mapping[str, DomainSpec], base_unit: str, unit_converter: type[BaseUnitConverter], ) -> type[EntityNumericalStateCrossedThresholdTriggerWithUnitBase]: diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index f454d51c48f..21a8ab6875f 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -42,7 +42,6 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.automation import ( DomainSpec, - NumericalDomainSpec, move_top_level_schema_fields_to_options, ) from homeassistant.helpers.condition import ( @@ -3610,7 +3609,7 @@ async def test_numerical_condition_with_unit_attribute_value_source( test = await _setup_numerical_condition_with_unit( hass, domain_specs={ - "test": NumericalDomainSpec(value_source="temperature"), + "test": DomainSpec(value_source="temperature"), }, condition_options={ "threshold": { @@ -3656,7 +3655,7 @@ async def test_numerical_condition_with_unit_get_entity_unit_override( class CustomCondition(EntityNumericalConditionWithUnitBase): """Condition that always reports entities as °F regardless of attributes.""" - _domain_specs = {"test": NumericalDomainSpec(value_source="temperature")} + _domain_specs = {"test": DomainSpec(value_source="temperature")} _base_unit = UnitOfTemperature.CELSIUS _unit_converter = TemperatureConverter diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 29092c7e02b..8ba53241771 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -37,9 +37,7 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, trigger from homeassistant.helpers.automation import ( - ANY_DEVICE_CLASS, DomainSpec, - NumericalDomainSpec, move_top_level_schema_fields_to_options, ) from homeassistant.helpers.trigger import ( @@ -1283,7 +1281,7 @@ async def test_numerical_state_attribute_changed_trigger_config_validation( async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "test_trigger": make_entity_numerical_state_changed_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")} + {"test": DomainSpec(value_source="test_attribute")} ), } @@ -1312,7 +1310,7 @@ def _make_with_unit_changed_trigger_class() -> type[ EntityNumericalStateChangedTriggerWithUnitBase, ): _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {"test": NumericalDomainSpec(value_source="test_attribute")} + _domain_specs = {"test": DomainSpec(value_source="test_attribute")} _unit_converter = TemperatureConverter return _TestChangedTrigger @@ -1514,7 +1512,7 @@ async def test_numerical_state_attribute_changed_error_handling( async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "attribute_changed": make_entity_numerical_state_changed_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")} + {"test": DomainSpec(value_source="test_attribute")} ), } @@ -1633,7 +1631,7 @@ async def test_numerical_state_attribute_changed_entity_limit_unit_validation( async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "attribute_changed": make_entity_numerical_state_changed_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")}, + {"test": DomainSpec(value_source="test_attribute")}, valid_unit="%", ), } @@ -2232,7 +2230,7 @@ async def test_numerical_state_attribute_crossed_threshold_trigger_config_valida async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "test_trigger": make_entity_numerical_state_crossed_threshold_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")} + {"test": DomainSpec(value_source="test_attribute")} ), } @@ -2261,7 +2259,7 @@ def _make_with_unit_crossed_threshold_trigger_class() -> type[ EntityNumericalStateCrossedThresholdTriggerWithUnitBase, ): _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {"test": NumericalDomainSpec(value_source="test_attribute")} + _domain_specs = {"test": DomainSpec(value_source="test_attribute")} _unit_converter = TemperatureConverter return _TestCrossedThresholdTrigger @@ -2414,7 +2412,7 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling( async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")} + {"test": DomainSpec(value_source="test_attribute")} ), } @@ -2540,7 +2538,7 @@ async def test_numerical_state_attribute_crossed_threshold_entity_limit_unit_val async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")}, + {"test": DomainSpec(value_source="test_attribute")}, valid_unit="%", ), } @@ -2850,21 +2848,6 @@ async def test_entity_filter_no_device_class_means_match_all_in_domain( assert result == entities -async def test_numerical_domain_spec_converter(hass: HomeAssistant) -> None: - """Test NumericalDomainSpec stores converter correctly.""" - converter = lambda v: float(v) / 255.0 * 100.0 # noqa: E731 - num_domain_spec = NumericalDomainSpec( - value_source="brightness", value_converter=converter - ) - assert num_domain_spec.value_source == "brightness" - assert num_domain_spec.value_converter is converter - assert num_domain_spec.device_class is ANY_DEVICE_CLASS - - # Plain DomainSpec has no converter - domain_spec = DomainSpec(value_source="brightness") - assert not isinstance(domain_spec, NumericalDomainSpec) - - @pytest.mark.parametrize( ("domain_specs", "to_states", "from_state", "to_state", "wrong_value_state"), [ From 6d56597a2a7a4fcb1823fd39bd8740c596859fb5 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Thu, 26 Mar 2026 14:32:52 +0100 Subject: [PATCH 036/138] Bump pooldose 0.9.0 (#166589) --- homeassistant/components/pooldose/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pooldose/manifest.json b/homeassistant/components/pooldose/manifest.json index 66f95e7c82f..acc8e90c3d6 100644 --- a/homeassistant/components/pooldose/manifest.json +++ b/homeassistant/components/pooldose/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["python-pooldose==0.8.6"] + "requirements": ["python-pooldose==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ed530439b31..0ec4ae7bdf6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2651,7 +2651,7 @@ python-overseerr==0.9.0 python-picnic-api2==1.3.1 # homeassistant.components.pooldose -python-pooldose==0.8.6 +python-pooldose==0.9.0 # homeassistant.components.hr_energy_qube python-qube-heatpump==1.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 829bcbfb963..420bdc5c085 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2253,7 +2253,7 @@ python-overseerr==0.9.0 python-picnic-api2==1.3.1 # homeassistant.components.pooldose -python-pooldose==0.8.6 +python-pooldose==0.9.0 # homeassistant.components.hr_energy_qube python-qube-heatpump==1.7.0 From 75a15ed24e880450f0cbfbc2f619f2dc99f54265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 26 Mar 2026 13:36:16 +0000 Subject: [PATCH 037/138] Add todo to experimental triggers (#166591) --- homeassistant/components/automation/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index b5fc6b5f015..8d4fc2ebc12 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -186,6 +186,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "switch", "temperature", "text", + "todo", "update", "vacuum", "water_heater", From 3afd763d16f7f5066657a5c44eddca510bcf0598 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 14:46:39 +0100 Subject: [PATCH 038/138] Remove number entity support from battery triggers and conditions (#166593) --- homeassistant/components/battery/condition.py | 2 - .../components/battery/conditions.yaml | 4 - homeassistant/components/battery/trigger.py | 2 - .../components/battery/triggers.yaml | 4 - tests/components/battery/test_condition.py | 6 - tests/components/battery/test_trigger.py | 126 ------------------ 6 files changed, 144 deletions(-) diff --git a/homeassistant/components/battery/condition.py b/homeassistant/components/battery/condition.py index 4f523e078d8..60f479aa4af 100644 --- a/homeassistant/components/battery/condition.py +++ b/homeassistant/components/battery/condition.py @@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -27,7 +26,6 @@ BATTERY_CHARGING_DOMAIN_SPECS = { } BATTERY_PERCENTAGE_DOMAIN_SPECS = { SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY), - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.BATTERY), } CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/battery/conditions.yaml b/homeassistant/components/battery/conditions.yaml index 98584b00044..6589e644bb3 100644 --- a/homeassistant/components/battery/conditions.yaml +++ b/homeassistant/components/battery/conditions.yaml @@ -19,8 +19,6 @@ unit_of_measurement: "%" - domain: sensor device_class: battery - - domain: number - device_class: battery .battery_threshold_number: &battery_threshold_number min: 0 @@ -53,8 +51,6 @@ is_level: entity: - domain: sensor device_class: battery - - domain: number - device_class: battery fields: behavior: *condition_behavior threshold: diff --git a/homeassistant/components/battery/trigger.py b/homeassistant/components/battery/trigger.py index 2547b70cd7d..426dae82569 100644 --- a/homeassistant/components/battery/trigger.py +++ b/homeassistant/components/battery/trigger.py @@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -30,7 +29,6 @@ BATTERY_CHARGING_DOMAIN_SPECS: dict[str, DomainSpec] = { BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = { SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY), - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.BATTERY), } TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/battery/triggers.yaml b/homeassistant/components/battery/triggers.yaml index a8f64995d3f..97a5c6e5515 100644 --- a/homeassistant/components/battery/triggers.yaml +++ b/homeassistant/components/battery/triggers.yaml @@ -13,8 +13,6 @@ .battery_threshold_entity: &battery_threshold_entity - domain: input_number unit_of_measurement: "%" - - domain: number - device_class: battery - domain: sensor device_class: battery @@ -38,8 +36,6 @@ entity: - domain: sensor device_class: battery - - domain: number - device_class: battery low: fields: diff --git a/tests/components/battery/test_condition.py b/tests/components/battery/test_condition.py index 230f57112f1..8c828c0add8 100644 --- a/tests/components/battery/test_condition.py +++ b/tests/components/battery/test_condition.py @@ -40,12 +40,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@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.mark.parametrize( "condition", [ diff --git a/tests/components/battery/test_trigger.py b/tests/components/battery/test_trigger.py index 16836f4bc35..e09eeda0efd 100644 --- a/tests/components/battery/test_trigger.py +++ b/tests/components/battery/test_trigger.py @@ -4,7 +4,6 @@ from typing import Any import pytest -from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -40,12 +39,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@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.mark.parametrize( "trigger_key", [ @@ -364,122 +357,3 @@ async def test_battery_level_crossed_threshold_sensor_behavior_last( 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_changed_trigger_states( - "battery.level_changed", - device_class=NumberDeviceClass.BATTERY, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "battery.level_crossed_threshold", - device_class=NumberDeviceClass.BATTERY, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_battery_number_trigger_behavior_any( - hass: HomeAssistant, - 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 battery number triggers with 'any' behavior.""" - await assert_trigger_behavior_any( - hass, - 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( - "battery.level_crossed_threshold", - device_class=NumberDeviceClass.BATTERY, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_battery_level_crossed_threshold_number_behavior_first( - hass: HomeAssistant, - 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 battery level_crossed_threshold trigger fires on the first number state change.""" - await assert_trigger_behavior_first( - hass, - 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( - "battery.level_crossed_threshold", - device_class=NumberDeviceClass.BATTERY, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_battery_level_crossed_threshold_number_behavior_last( - hass: HomeAssistant, - 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 battery level_crossed_threshold trigger fires when the last number changes state.""" - await assert_trigger_behavior_last( - hass, - 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, - ) From 5c53b847dc4e99d63bf1d942bd8d8581da1b3022 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 15:49:40 +0100 Subject: [PATCH 039/138] Remove number entity support from humidity triggers and conditions (#166594) --- .../components/humidity/condition.py | 2 - .../components/humidity/conditions.yaml | 2 - tests/components/humidity/test_condition.py | 78 ------------------- 3 files changed, 82 deletions(-) diff --git a/homeassistant/components/humidity/condition.py b/homeassistant/components/humidity/condition.py index 818f649e2c2..6a990837b0c 100644 --- a/homeassistant/components/humidity/condition.py +++ b/homeassistant/components/humidity/condition.py @@ -10,7 +10,6 @@ from homeassistant.components.humidifier import ( ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY, DOMAIN as HUMIDIFIER_DOMAIN, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant @@ -25,7 +24,6 @@ HUMIDITY_DOMAIN_SPECS = { value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.HUMIDITY), - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.HUMIDITY), } CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/humidity/conditions.yaml b/homeassistant/components/humidity/conditions.yaml index 733b2452891..2f518db77d8 100644 --- a/homeassistant/components/humidity/conditions.yaml +++ b/homeassistant/components/humidity/conditions.yaml @@ -17,8 +17,6 @@ is_value: entity: - domain: sensor device_class: humidity - - domain: number - device_class: humidity - domain: climate - domain: humidifier fields: diff --git a/tests/components/humidity/test_condition.py b/tests/components/humidity/test_condition.py index 345e0f43339..e71bbf7ded1 100644 --- a/tests/components/humidity/test_condition.py +++ b/tests/components/humidity/test_condition.py @@ -36,12 +36,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@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_climates(hass: HomeAssistant) -> dict[str, list[str]]: """Create multiple climate entities associated with different targets.""" @@ -139,78 +133,6 @@ async def test_humidity_sensor_condition_behavior_all( ) -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_any( - "humidity.is_value", - device_class="humidity", - unit_attributes=_HUMIDITY_UNIT_ATTRS, - ), -) -async def test_humidity_number_condition_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the humidity number condition with 'any' behavior.""" - await assert_condition_behavior_any( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_all( - "humidity.is_value", - device_class="humidity", - unit_attributes=_HUMIDITY_UNIT_ATTRS, - ), -) -async def test_humidity_number_condition_behavior_all( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the humidity number condition with 'all' behavior.""" - await assert_condition_behavior_all( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), From 16999e370708b396d1917c1df6907229c6ddd2ed Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:08:28 +0100 Subject: [PATCH 040/138] Remove number entity support from illuminance triggers and conditions (#166595) --- .../components/illuminance/condition.py | 2 - .../components/illuminance/conditions.yaml | 2 - .../components/illuminance/trigger.py | 2 - .../components/illuminance/triggers.yaml | 2 - .../components/illuminance/test_condition.py | 78 ----------- tests/components/illuminance/test_trigger.py | 129 ------------------ 6 files changed, 215 deletions(-) diff --git a/homeassistant/components/illuminance/condition.py b/homeassistant/components/illuminance/condition.py index 97bdbd330e9..c074c333100 100644 --- a/homeassistant/components/illuminance/condition.py +++ b/homeassistant/components/illuminance/condition.py @@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -22,7 +21,6 @@ ILLUMINANCE_DETECTED_DOMAIN_SPECS = { } ILLUMINANCE_VALUE_DOMAIN_SPECS = { SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.ILLUMINANCE), - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.ILLUMINANCE), } CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/illuminance/conditions.yaml b/homeassistant/components/illuminance/conditions.yaml index 37980efcae4..b23ac8007e0 100644 --- a/homeassistant/components/illuminance/conditions.yaml +++ b/homeassistant/components/illuminance/conditions.yaml @@ -23,8 +23,6 @@ is_value: entity: - domain: sensor device_class: illuminance - - domain: number - device_class: illuminance fields: behavior: *condition_behavior threshold: diff --git a/homeassistant/components/illuminance/trigger.py b/homeassistant/components/illuminance/trigger.py index 042a207beb6..56fe4910809 100644 --- a/homeassistant/components/illuminance/trigger.py +++ b/homeassistant/components/illuminance/trigger.py @@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -19,7 +18,6 @@ from homeassistant.helpers.trigger import ( ) ILLUMINANCE_DOMAIN_SPECS: dict[str, DomainSpec] = { - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.ILLUMINANCE), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.ILLUMINANCE), } diff --git a/homeassistant/components/illuminance/triggers.yaml b/homeassistant/components/illuminance/triggers.yaml index d015efb36b8..c2f77fd4292 100644 --- a/homeassistant/components/illuminance/triggers.yaml +++ b/homeassistant/components/illuminance/triggers.yaml @@ -29,8 +29,6 @@ .trigger_numerical_target: &trigger_numerical_target entity: - - domain: number - device_class: illuminance - domain: sensor device_class: illuminance diff --git a/tests/components/illuminance/test_condition.py b/tests/components/illuminance/test_condition.py index 9a3b0c16c9c..d82a29581c3 100644 --- a/tests/components/illuminance/test_condition.py +++ b/tests/components/illuminance/test_condition.py @@ -40,12 +40,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@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.mark.parametrize( "condition", [ @@ -221,75 +215,3 @@ async def test_illuminance_value_condition_behavior_all( condition_options=condition_options, states=states, ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_any( - "illuminance.is_value", - device_class="illuminance", - unit_attributes=_ILLUMINANCE_UNIT_ATTRS, - ), -) -async def test_illuminance_value_number_condition_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the illuminance value condition with number entities and 'any' behavior.""" - await assert_condition_behavior_any( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_all( - "illuminance.is_value", - device_class="illuminance", - unit_attributes=_ILLUMINANCE_UNIT_ATTRS, - ), -) -async def test_illuminance_value_number_condition_behavior_all( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the illuminance value condition with number entities and 'all' behavior.""" - await assert_condition_behavior_all( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) diff --git a/tests/components/illuminance/test_trigger.py b/tests/components/illuminance/test_trigger.py index a53b218d5b7..908cb0fe39a 100644 --- a/tests/components/illuminance/test_trigger.py +++ b/tests/components/illuminance/test_trigger.py @@ -5,7 +5,6 @@ from typing import Any import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -36,12 +35,6 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "binary_sensor") -@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.""" @@ -340,125 +333,3 @@ async def test_illuminance_trigger_sensor_crossed_threshold_behavior_last( trigger_options=trigger_options, states=states, ) - - -# --- Number changed/crossed_threshold 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( - "illuminance.changed", - device_class=NumberDeviceClass.ILLUMINANCE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, - ), - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "illuminance.crossed_threshold", - device_class=NumberDeviceClass.ILLUMINANCE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, - ), - ], -) -async def test_illuminance_trigger_number_behavior_any( - hass: HomeAssistant, - 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 illuminance trigger fires for number entities with device_class illuminance.""" - await assert_trigger_behavior_any( - hass, - 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( - "illuminance.crossed_threshold", - device_class=NumberDeviceClass.ILLUMINANCE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, - ), - ], -) -async def test_illuminance_trigger_number_crossed_threshold_behavior_first( - hass: HomeAssistant, - 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 illuminance crossed_threshold trigger fires on the first number state change.""" - await assert_trigger_behavior_first( - hass, - 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( - "illuminance.crossed_threshold", - device_class=NumberDeviceClass.ILLUMINANCE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, - ), - ], -) -async def test_illuminance_trigger_number_crossed_threshold_behavior_last( - hass: HomeAssistant, - 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 illuminance crossed_threshold trigger fires when the last number changes state.""" - await assert_trigger_behavior_last( - hass, - 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, - ) From 2ea4d7913ef4ae05712b91e4d4b3fc71f32105cb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 14:40:56 +0100 Subject: [PATCH 041/138] Remove number entity support from moisture triggers and conditions (#166596) --- .../components/moisture/condition.py | 2 - .../components/moisture/conditions.yaml | 4 - homeassistant/components/moisture/trigger.py | 2 - .../components/moisture/triggers.yaml | 4 - tests/components/moisture/test_condition.py | 78 ----------- tests/components/moisture/test_trigger.py | 129 ------------------ 6 files changed, 219 deletions(-) diff --git a/homeassistant/components/moisture/condition.py b/homeassistant/components/moisture/condition.py index aaeee6359e1..2c789480d8d 100644 --- a/homeassistant/components/moisture/condition.py +++ b/homeassistant/components/moisture/condition.py @@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -25,7 +24,6 @@ _MOISTURE_BINARY_DOMAIN_SPECS = { _MOISTURE_NUMERICAL_DOMAIN_SPECS = { SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.MOISTURE), - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.MOISTURE), } CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/moisture/conditions.yaml b/homeassistant/components/moisture/conditions.yaml index a1e1f9b4bfd..4c4899de858 100644 --- a/homeassistant/components/moisture/conditions.yaml +++ b/homeassistant/components/moisture/conditions.yaml @@ -19,8 +19,6 @@ unit_of_measurement: "%" - domain: sensor device_class: moisture - - domain: number - device_class: moisture .moisture_threshold_number: &moisture_threshold_number min: 0 @@ -37,8 +35,6 @@ is_value: entity: - domain: sensor device_class: moisture - - domain: number - device_class: moisture fields: behavior: *condition_behavior threshold: diff --git a/homeassistant/components/moisture/trigger.py b/homeassistant/components/moisture/trigger.py index 6c50a83a952..08c14ecf0eb 100644 --- a/homeassistant/components/moisture/trigger.py +++ b/homeassistant/components/moisture/trigger.py @@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -23,7 +22,6 @@ MOISTURE_BINARY_DOMAIN_SPECS: dict[str, DomainSpec] = { } MOISTURE_NUMERICAL_DOMAIN_SPECS: dict[str, DomainSpec] = { - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.MOISTURE), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.MOISTURE), } diff --git a/homeassistant/components/moisture/triggers.yaml b/homeassistant/components/moisture/triggers.yaml index 2453da578ac..a111c58ecc9 100644 --- a/homeassistant/components/moisture/triggers.yaml +++ b/homeassistant/components/moisture/triggers.yaml @@ -13,8 +13,6 @@ .moisture_threshold_entity: &moisture_threshold_entity - domain: input_number unit_of_measurement: "%" - - domain: number - device_class: moisture - domain: sensor device_class: moisture @@ -31,8 +29,6 @@ .trigger_numerical_target: &trigger_numerical_target entity: - - domain: number - device_class: moisture - domain: sensor device_class: moisture diff --git a/tests/components/moisture/test_condition.py b/tests/components/moisture/test_condition.py index e56834fb5d1..65d7e7c76d0 100644 --- a/tests/components/moisture/test_condition.py +++ b/tests/components/moisture/test_condition.py @@ -40,12 +40,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@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.mark.parametrize( "condition", [ @@ -221,75 +215,3 @@ async def test_moisture_sensor_condition_behavior_all( condition_options=condition_options, states=states, ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_any( - "moisture.is_value", - device_class="moisture", - unit_attributes=_MOISTURE_UNIT_ATTRS, - ), -) -async def test_moisture_number_condition_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the moisture number condition with 'any' behavior.""" - await assert_condition_behavior_any( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_all( - "moisture.is_value", - device_class="moisture", - unit_attributes=_MOISTURE_UNIT_ATTRS, - ), -) -async def test_moisture_number_condition_behavior_all( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the moisture number condition with 'all' behavior.""" - await assert_condition_behavior_all( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) diff --git a/tests/components/moisture/test_trigger.py b/tests/components/moisture/test_trigger.py index 4137a661aa3..1d6d304e779 100644 --- a/tests/components/moisture/test_trigger.py +++ b/tests/components/moisture/test_trigger.py @@ -5,7 +5,6 @@ from typing import Any import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -36,12 +35,6 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "binary_sensor") -@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.""" @@ -336,128 +329,6 @@ async def test_moisture_trigger_sensor_crossed_threshold_behavior_last( ) -# --- 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( - "moisture.changed", - device_class=NumberDeviceClass.MOISTURE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "moisture.crossed_threshold", - device_class=NumberDeviceClass.MOISTURE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_moisture_trigger_number_behavior_any( - hass: HomeAssistant, - 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 moisture trigger fires for number entities with device_class moisture.""" - await assert_trigger_behavior_any( - hass, - 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( - "moisture.crossed_threshold", - device_class=NumberDeviceClass.MOISTURE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_moisture_trigger_number_crossed_threshold_behavior_first( - hass: HomeAssistant, - 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 moisture crossed_threshold trigger fires on the first number state change.""" - await assert_trigger_behavior_first( - hass, - 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( - "moisture.crossed_threshold", - device_class=NumberDeviceClass.MOISTURE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_moisture_trigger_number_crossed_threshold_behavior_last( - hass: HomeAssistant, - 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 moisture crossed_threshold trigger fires when the last number changes state.""" - await assert_trigger_behavior_last( - hass, - 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", "trigger_options", "limit_entities"), From da9d1080d96af2dfcb53c0c2c4c0fd16654efc69 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:05:21 +0100 Subject: [PATCH 042/138] Remove number entity support from power triggers and conditions (#166597) --- homeassistant/components/power/condition.py | 2 - .../components/power/conditions.yaml | 2 - homeassistant/components/power/trigger.py | 2 - homeassistant/components/power/triggers.yaml | 2 - tests/components/power/test_condition.py | 80 ----------- tests/components/power/test_trigger.py | 133 ------------------ 6 files changed, 221 deletions(-) diff --git a/homeassistant/components/power/condition.py b/homeassistant/components/power/condition.py index 78a8ee98ae0..114417a8d57 100644 --- a/homeassistant/components/power/condition.py +++ b/homeassistant/components/power/condition.py @@ -2,7 +2,6 @@ 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 @@ -14,7 +13,6 @@ from homeassistant.helpers.condition import ( from homeassistant.util.unit_conversion import PowerConverter POWER_DOMAIN_SPECS = { - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.POWER), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER), } diff --git a/homeassistant/components/power/conditions.yaml b/homeassistant/components/power/conditions.yaml index a34beb6d24a..63f2c82b20f 100644 --- a/homeassistant/components/power/conditions.yaml +++ b/homeassistant/components/power/conditions.yaml @@ -28,8 +28,6 @@ is_value: target: entity: - - domain: number - device_class: power - domain: sensor device_class: power fields: diff --git a/homeassistant/components/power/trigger.py b/homeassistant/components/power/trigger.py index b1e8274527f..b43dc072f7a 100644 --- a/homeassistant/components/power/trigger.py +++ b/homeassistant/components/power/trigger.py @@ -2,7 +2,6 @@ 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 @@ -15,7 +14,6 @@ from homeassistant.helpers.trigger import ( from homeassistant.util.unit_conversion import PowerConverter POWER_DOMAIN_SPECS: dict[str, DomainSpec] = { - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.POWER), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER), } diff --git a/homeassistant/components/power/triggers.yaml b/homeassistant/components/power/triggers.yaml index 88d2f765c84..22dac96db36 100644 --- a/homeassistant/components/power/triggers.yaml +++ b/homeassistant/components/power/triggers.yaml @@ -29,8 +29,6 @@ .trigger_target: &trigger_target entity: - - domain: number - device_class: power - domain: sensor device_class: power diff --git a/tests/components/power/test_condition.py b/tests/components/power/test_condition.py index b469d7ac2b6..e5bff95dff5 100644 --- a/tests/components/power/test_condition.py +++ b/tests/components/power/test_condition.py @@ -26,12 +26,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@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.mark.parametrize( "condition", ["power.is_value"], @@ -117,80 +111,6 @@ async def test_power_sensor_condition_behavior_all( ) -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_any( - "power.is_value", - device_class="power", - threshold_unit=UnitOfPower.WATT, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, - ), -) -async def test_power_number_condition_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the power number condition with 'any' behavior.""" - await assert_condition_behavior_any( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_all( - "power.is_value", - device_class="power", - threshold_unit=UnitOfPower.WATT, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, - ), -) -async def test_power_number_condition_behavior_all( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the power number condition with 'all' behavior.""" - await assert_condition_behavior_all( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - @pytest.mark.usefixtures("enable_labs_preview_features") async def test_power_condition_unit_conversion_sensor( hass: HomeAssistant, diff --git a/tests/components/power/test_trigger.py b/tests/components/power/test_trigger.py index a7599dca96c..f56f1bcaed4 100644 --- a/tests/components/power/test_trigger.py +++ b/tests/components/power/test_trigger.py @@ -4,7 +4,6 @@ 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 @@ -24,12 +23,6 @@ from tests.components.common import ( _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.""" @@ -171,129 +164,3 @@ async def test_power_trigger_sensor_crossed_threshold_behavior_last( 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, - threshold_unit=UnitOfPower.WATT, - unit_attributes=_POWER_UNIT_ATTRIBUTES, - ), - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "power.crossed_threshold", - device_class=NumberDeviceClass.POWER, - threshold_unit=UnitOfPower.WATT, - unit_attributes=_POWER_UNIT_ATTRIBUTES, - ), - ], -) -async def test_power_trigger_number_behavior_any( - hass: HomeAssistant, - 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, - 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, - threshold_unit=UnitOfPower.WATT, - unit_attributes=_POWER_UNIT_ATTRIBUTES, - ), - ], -) -async def test_power_trigger_number_crossed_threshold_behavior_first( - hass: HomeAssistant, - 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, - 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, - threshold_unit=UnitOfPower.WATT, - unit_attributes=_POWER_UNIT_ATTRIBUTES, - ), - ], -) -async def test_power_trigger_number_crossed_threshold_behavior_last( - hass: HomeAssistant, - 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, - 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, - ) From 6f32a537426a70afc71b061376ad6f84023648f5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 26 Mar 2026 17:06:40 +0100 Subject: [PATCH 043/138] Make `siren` conditions consistent with new wording (#166600) --- homeassistant/components/siren/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json index 9feaa614d5d..b33c2592255 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -14,7 +14,7 @@ "name": "[%key:component::siren::common::condition_behavior_name%]" } }, - "name": "If a siren is off" + "name": "Siren is off" }, "is_on": { "description": "Tests if one or more sirens are on.", @@ -24,7 +24,7 @@ "name": "[%key:component::siren::common::condition_behavior_name%]" } }, - "name": "If a siren is on" + "name": "Siren is on" } }, "entity_component": { From 24c0b22038c90e76e0533095b8d448de48314037 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 17:58:44 +0100 Subject: [PATCH 044/138] Add light.is_brightness condition (#166601) --- homeassistant/components/light/condition.py | 37 +++- .../components/light/conditions.yaml | 30 ++- homeassistant/components/light/icons.json | 3 + homeassistant/components/light/strings.json | 16 ++ tests/components/light/test_condition.py | 186 ++++++++++++++++++ tests/helpers/test_condition.py | 18 +- 6 files changed, 284 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/light/condition.py b/homeassistant/components/light/condition.py index 59fcd10c831..57593bbc218 100644 --- a/homeassistant/components/light/condition.py +++ b/homeassistant/components/light/condition.py @@ -1,12 +1,43 @@ """Provides conditions for lights.""" -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.helpers.condition import Condition, make_entity_state_condition +from typing import Any +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import ( + Condition, + EntityNumericalConditionBase, + make_entity_state_condition, +) + +from . import ATTR_BRIGHTNESS from .const import DOMAIN +BRIGHTNESS_DOMAIN_SPECS = { + DOMAIN: DomainSpec(value_source=ATTR_BRIGHTNESS), +} + + +class BrightnessCondition(EntityNumericalConditionBase): + """Condition for light brightness with uint8 to percentage conversion.""" + + _domain_specs = BRIGHTNESS_DOMAIN_SPECS + _valid_unit = "%" + + def _get_tracked_value(self, entity_state: State) -> Any: + """Get the brightness value converted from uint8 (0-255) to percentage (0-100).""" + raw = super()._get_tracked_value(entity_state) + if raw is None: + return None + try: + return (float(raw) / 255.0) * 100.0 + except TypeError, ValueError: + return None + + CONDITIONS: dict[str, type[Condition]] = { + "is_brightness": BrightnessCondition, "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), "is_on": make_entity_state_condition(DOMAIN, STATE_ON), } diff --git a/homeassistant/components/light/conditions.yaml b/homeassistant/components/light/conditions.yaml index a35a581ffc1..229707d6c89 100644 --- a/homeassistant/components/light/conditions.yaml +++ b/homeassistant/components/light/conditions.yaml @@ -1,9 +1,9 @@ .condition_common: &condition_common - target: + target: &condition_light_target entity: domain: light fields: - behavior: + behavior: &condition_behavior required: true default: any selector: @@ -13,5 +13,31 @@ - all - any +.brightness_threshold_entity: &brightness_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: number + unit_of_measurement: "%" + - domain: sensor + unit_of_measurement: "%" + +.brightness_threshold_number: &brightness_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" + is_off: *condition_common is_on: *condition_common + +is_brightness: + target: *condition_light_target + fields: + behavior: *condition_behavior + threshold: + required: true + selector: + numeric_threshold: + entity: *brightness_threshold_entity + mode: is + number: *brightness_threshold_number diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index 8fee85b7024..8a5716f774a 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -1,5 +1,8 @@ { "conditions": { + "is_brightness": { + "condition": "mdi:lightbulb-on-50" + }, "is_off": { "condition": "mdi:lightbulb-off" }, diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index d77b9ad0ea7..dd0ae383c92 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted lights.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "field_brightness_description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness.", "field_brightness_name": "Brightness value", "field_brightness_pct_description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness.", @@ -42,6 +44,20 @@ "trigger_threshold_name": "Threshold configuration" }, "conditions": { + "is_brightness": { + "description": "Tests the brightness of one or more lights.", + "fields": { + "behavior": { + "description": "[%key:component::light::common::condition_behavior_description%]", + "name": "[%key:component::light::common::condition_behavior_name%]" + }, + "threshold": { + "description": "[%key:component::light::common::condition_threshold_description%]", + "name": "[%key:component::light::common::condition_threshold_name%]" + } + }, + "name": "Light brightness" + }, "is_off": { "description": "Tests if one or more lights are off.", "fields": { diff --git a/tests/components/light/test_condition.py b/tests/components/light/test_condition.py index ca41f5f415f..0d55a992af1 100644 --- a/tests/components/light/test_condition.py +++ b/tests/components/light/test_condition.py @@ -4,11 +4,14 @@ from typing import Any import pytest +from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, assert_condition_gated_by_labs_flag, create_target_condition, parametrize_condition_states_all, @@ -19,6 +22,116 @@ from tests.components.common import ( ) +def parametrize_brightness_condition_states_any( + condition: str, state: str, attribute: str +) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: + """Parametrize above/below threshold test cases for brightness conditions. + + Note: The brightness in the condition configuration is in percentage (0-100) scale, + the underlying attribute in the state is in uint8 (0-255) scale. + """ + return [ + *parametrize_condition_states_any( + condition=condition, + condition_options={"threshold": {"type": "above", "value": {"number": 10}}}, + target_states=[ + (state, {attribute: 128}), + (state, {attribute: 255}), + ], + other_states=[ + (state, {attribute: 0}), + (state, {attribute: None}), + ], + ), + *parametrize_condition_states_any( + condition=condition, + condition_options={"threshold": {"type": "below", "value": {"number": 90}}}, + target_states=[ + (state, {attribute: 0}), + (state, {attribute: 128}), + ], + other_states=[ + (state, {attribute: 255}), + (state, {attribute: None}), + ], + ), + *parametrize_condition_states_any( + condition=condition, + condition_options={ + "threshold": { + "type": "between", + "value_min": {"number": 10}, + "value_max": {"number": 90}, + } + }, + target_states=[ + (state, {attribute: 128}), + (state, {attribute: 153}), + ], + other_states=[ + (state, {attribute: 0}), + (state, {attribute: 255}), + (state, {attribute: None}), + ], + ), + ] + + +def parametrize_brightness_condition_states_all( + condition: str, state: str, attribute: str +) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: + """Parametrize above/below threshold test cases for brightness conditions with 'all' behavior. + + Note: The brightness in the condition configuration is in percentage (0-100) scale, + the underlying attribute in the state is in uint8 (0-255) scale. + """ + return [ + *parametrize_condition_states_all( + condition=condition, + condition_options={"threshold": {"type": "above", "value": {"number": 10}}}, + target_states=[ + (state, {attribute: 128}), + (state, {attribute: 255}), + ], + other_states=[ + (state, {attribute: 0}), + (state, {attribute: None}), + ], + ), + *parametrize_condition_states_all( + condition=condition, + condition_options={"threshold": {"type": "below", "value": {"number": 90}}}, + target_states=[ + (state, {attribute: 0}), + (state, {attribute: 128}), + ], + other_states=[ + (state, {attribute: 255}), + (state, {attribute: None}), + ], + ), + *parametrize_condition_states_all( + condition=condition, + condition_options={ + "threshold": { + "type": "between", + "value_min": {"number": 10}, + "value_max": {"number": 90}, + } + }, + target_states=[ + (state, {attribute: 128}), + (state, {attribute: 153}), + ], + other_states=[ + (state, {attribute: 0}), + (state, {attribute: 255}), + (state, {attribute: None}), + ], + ), + ] + + @pytest.fixture async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]: """Create multiple light entities associated with different targets.""" @@ -38,6 +151,7 @@ async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]: @pytest.mark.parametrize( "condition", [ + "light.is_brightness", "light.is_off", "light.is_on", ], @@ -176,3 +290,75 @@ async def test_light_state_condition_behavior_all( await hass.async_block_till_done() assert condition(hass) == state["condition_true"] + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("light"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_brightness_condition_states_any( + "light.is_brightness", STATE_ON, ATTR_BRIGHTNESS + ), + ], +) +async def test_light_brightness_condition_behavior_any( + hass: HomeAssistant, + target_lights: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the light brightness condition with the 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_lights, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("light"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_brightness_condition_states_all( + "light.is_brightness", STATE_ON, ATTR_BRIGHTNESS + ), + ], +) +async def test_light_brightness_condition_behavior_all( + hass: HomeAssistant, + target_lights: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the light brightness condition with the 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_lights, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 21a8ab6875f..e21a3d048d0 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2540,6 +2540,10 @@ async def test_async_get_all_descriptions( target: entity: domain: light + is_brightness: + target: + entity: + domain: light """ ws_client = await hass_ws_client(hass) @@ -2725,6 +2729,18 @@ async def test_async_get_all_descriptions( ], }, }, + "light.is_brightness": { + "fields": {}, + "target": { + "entity": [ + { + "domain": [ + "light", + ], + }, + ], + }, + }, } # Verify the cache returns the same object @@ -2898,7 +2914,7 @@ async def test_subscribe_conditions( @pytest.mark.parametrize( ("new_triggers_conditions_enabled", "expected_events"), [ - (True, [{"light.is_off", "light.is_on"}]), + (True, [{"light.is_off", "light.is_on", "light.is_brightness"}]), (False, []), ], ) From e60048ef308d87780379f6d83e1cfcd3544d4431 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:51:51 +0100 Subject: [PATCH 045/138] Add input_boolean support to switch conditions (#166602) --- homeassistant/components/switch/condition.py | 8 +- .../components/switch/conditions.yaml | 3 +- tests/components/switch/test_condition.py | 135 +++++++++++++++++- 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switch/condition.py b/homeassistant/components/switch/condition.py index 283524d2178..65d61ce723c 100644 --- a/homeassistant/components/switch/condition.py +++ b/homeassistant/components/switch/condition.py @@ -1,14 +1,18 @@ """Provides conditions for switches.""" +from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import Condition, make_entity_state_condition from .const import DOMAIN +SWITCH_DOMAIN_SPECS = {DOMAIN: DomainSpec(), INPUT_BOOLEAN_DOMAIN: DomainSpec()} + CONDITIONS: dict[str, type[Condition]] = { - "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), - "is_on": make_entity_state_condition(DOMAIN, STATE_ON), + "is_off": make_entity_state_condition(SWITCH_DOMAIN_SPECS, STATE_OFF), + "is_on": make_entity_state_condition(SWITCH_DOMAIN_SPECS, STATE_ON), } diff --git a/homeassistant/components/switch/conditions.yaml b/homeassistant/components/switch/conditions.yaml index f293d020b45..ea9adeb6f74 100644 --- a/homeassistant/components/switch/conditions.yaml +++ b/homeassistant/components/switch/conditions.yaml @@ -1,7 +1,8 @@ .condition_common: &condition_common target: entity: - domain: switch + - domain: switch + - domain: input_boolean fields: behavior: required: true diff --git a/tests/components/switch/test_condition.py b/tests/components/switch/test_condition.py index eaa906fca04..76a0dffffb8 100644 --- a/tests/components/switch/test_condition.py +++ b/tests/components/switch/test_condition.py @@ -4,11 +4,13 @@ from typing import Any import pytest -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import CONF_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, assert_condition_gated_by_labs_flag, create_target_condition, parametrize_condition_states_all, @@ -35,6 +37,12 @@ async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "switch") +@pytest.fixture +async def target_input_booleans(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple input_boolean entities associated with different targets.""" + return await target_entities(hass, "input_boolean") + + @pytest.mark.parametrize( "condition", [ @@ -176,3 +184,128 @@ async def test_switch_state_condition_behavior_all( await hass.async_block_till_done() assert condition(hass) == state["condition_true"] + + +CONDITION_STATES = [ + *parametrize_condition_states_any( + condition="switch.is_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + *parametrize_condition_states_any( + condition="switch.is_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), +] + +CONDITION_STATES_ALL = [ + *parametrize_condition_states_all( + condition="switch.is_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + *parametrize_condition_states_all( + condition="switch.is_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), +] + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("input_boolean"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + CONDITION_STATES, +) +async def test_input_boolean_state_condition_behavior_any( + hass: HomeAssistant, + target_input_booleans: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the switch condition fires for input_boolean with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_input_booleans, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("input_boolean"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + CONDITION_STATES_ALL, +) +async def test_input_boolean_state_condition_behavior_all( + hass: HomeAssistant, + target_input_booleans: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the switch condition fires for input_boolean with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_input_booleans, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_switch_condition_evaluates_both_domains( + hass: HomeAssistant, +) -> None: + """Test that the switch condition evaluates both switch and input_boolean entities.""" + entity_id_switch = "switch.test_switch" + entity_id_input_boolean = "input_boolean.test_input_boolean" + + hass.states.async_set(entity_id_switch, STATE_OFF) + hass.states.async_set(entity_id_input_boolean, STATE_OFF) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition="switch.is_on", + target={CONF_ENTITY_ID: [entity_id_switch, entity_id_input_boolean]}, + behavior="any", + ) + + # Both off - condition should be false + assert condition(hass) is False + + # switch entity turns on - condition should be true + hass.states.async_set(entity_id_switch, STATE_ON) + await hass.async_block_till_done() + assert condition(hass) is True + + # Reset switch, turn on input_boolean - condition should still be true + hass.states.async_set(entity_id_switch, STATE_OFF) + hass.states.async_set(entity_id_input_boolean, STATE_ON) + await hass.async_block_till_done() + assert condition(hass) is True From ff79943776fd77501cb80fa82c099a67b87b9eb9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:35:59 +0100 Subject: [PATCH 046/138] Restore support for number entities as limits in battery conditions and triggers (#166607) --- homeassistant/components/battery/conditions.yaml | 2 ++ homeassistant/components/battery/triggers.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/battery/conditions.yaml b/homeassistant/components/battery/conditions.yaml index 6589e644bb3..9bd7c1f3596 100644 --- a/homeassistant/components/battery/conditions.yaml +++ b/homeassistant/components/battery/conditions.yaml @@ -19,6 +19,8 @@ unit_of_measurement: "%" - domain: sensor device_class: battery + - domain: number + device_class: battery .battery_threshold_number: &battery_threshold_number min: 0 diff --git a/homeassistant/components/battery/triggers.yaml b/homeassistant/components/battery/triggers.yaml index 97a5c6e5515..2ca59cf423f 100644 --- a/homeassistant/components/battery/triggers.yaml +++ b/homeassistant/components/battery/triggers.yaml @@ -13,6 +13,8 @@ .battery_threshold_entity: &battery_threshold_entity - domain: input_number unit_of_measurement: "%" + - domain: number + device_class: battery - domain: sensor device_class: battery From af9f351fceefd77fca47b451eeb56907f83531f9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:42:51 +0100 Subject: [PATCH 047/138] Restore support for number entities as limits in moisture conditions and triggers (#166608) --- homeassistant/components/moisture/conditions.yaml | 2 ++ homeassistant/components/moisture/triggers.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/moisture/conditions.yaml b/homeassistant/components/moisture/conditions.yaml index 4c4899de858..2bdf154950c 100644 --- a/homeassistant/components/moisture/conditions.yaml +++ b/homeassistant/components/moisture/conditions.yaml @@ -19,6 +19,8 @@ unit_of_measurement: "%" - domain: sensor device_class: moisture + - domain: number + device_class: moisture .moisture_threshold_number: &moisture_threshold_number min: 0 diff --git a/homeassistant/components/moisture/triggers.yaml b/homeassistant/components/moisture/triggers.yaml index a111c58ecc9..a8225e53b7e 100644 --- a/homeassistant/components/moisture/triggers.yaml +++ b/homeassistant/components/moisture/triggers.yaml @@ -13,6 +13,8 @@ .moisture_threshold_entity: &moisture_threshold_entity - domain: input_number unit_of_measurement: "%" + - domain: number + device_class: moisture - domain: sensor device_class: moisture From 02599a4a6ecabe296f76a7d2f9813e74f89f577a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 17:14:11 +0100 Subject: [PATCH 048/138] Add condition humidifier.is_mode (#166610) --- .../components/humidifier/condition.py | 63 ++++++++++++- .../components/humidifier/conditions.yaml | 13 +++ .../components/humidifier/icons.json | 3 + .../components/humidifier/strings.json | 14 +++ tests/components/humidifier/test_condition.py | 92 ++++++++++++++++++- 5 files changed, 182 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/humidifier/condition.py b/homeassistant/components/humidifier/condition.py index 0795291ae97..2a96eaffe37 100644 --- a/homeassistant/components/humidifier/condition.py +++ b/homeassistant/components/humidifier/condition.py @@ -1,15 +1,73 @@ """Provides conditions for humidifiers.""" -from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import ATTR_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, + ConditionConfig, + EntityStateConditionBase, make_entity_numerical_condition, make_entity_state_condition, ) +from homeassistant.helpers.entity import get_supported_features + +from .const import ( + ATTR_ACTION, + ATTR_HUMIDITY, + DOMAIN, + HumidifierAction, + HumidifierEntityFeature, +) + +CONF_MODE = "mode" + +IS_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]), + }, + } +) + + +def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool: + """Test if an entity supports the specified features.""" + try: + return bool(get_supported_features(hass, entity_id) & features) + except HomeAssistantError: + return False + + +class IsModeCondition(EntityStateConditionBase): + """Condition for humidifier mode.""" + + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)} + _schema = IS_MODE_CONDITION_SCHEMA + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize the mode condition.""" + super().__init__(hass, config) + if TYPE_CHECKING: + assert config.options is not None + self._states = set(config.options[CONF_MODE]) + + def entity_filter(self, entities: set[str]) -> set[str]: + """Filter entities of this domain.""" + entities = super().entity_filter(entities) + return { + entity_id + for entity_id in entities + if _supports_feature(self._hass, entity_id, HumidifierEntityFeature.MODES) + } -from .const import ATTR_ACTION, ATTR_HUMIDITY, DOMAIN, HumidifierAction CONDITIONS: dict[str, type[Condition]] = { "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), @@ -20,6 +78,7 @@ CONDITIONS: dict[str, type[Condition]] = { "is_humidifying": make_entity_state_condition( {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING ), + "is_mode": IsModeCondition, "is_target_humidity": make_entity_numerical_condition( {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, valid_unit=PERCENTAGE, diff --git a/homeassistant/components/humidifier/conditions.yaml b/homeassistant/components/humidifier/conditions.yaml index bc10ab1db65..25c29301f26 100644 --- a/homeassistant/components/humidifier/conditions.yaml +++ b/homeassistant/components/humidifier/conditions.yaml @@ -32,6 +32,19 @@ is_on: *condition_common is_drying: *condition_common is_humidifying: *condition_common +is_mode: + target: *condition_humidifier_target + fields: + behavior: *condition_behavior + mode: + context: + filter_target: target + required: true + selector: + state: + attribute: available_modes + multiple: true + is_target_humidity: target: *condition_humidifier_target fields: diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json index 778aa6d0f47..8f4e3f89a11 100644 --- a/homeassistant/components/humidifier/icons.json +++ b/homeassistant/components/humidifier/icons.json @@ -6,6 +6,9 @@ "is_humidifying": { "condition": "mdi:arrow-up-bold" }, + "is_mode": { + "condition": "mdi:air-humidifier" + }, "is_off": { "condition": "mdi:air-humidifier-off" }, diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 6acd851b3de..82ae8b57436 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -28,6 +28,20 @@ }, "name": "Humidifier is humidifying" }, + "is_mode": { + "description": "Tests if one or more humidifiers are set to a specific mode.", + "fields": { + "behavior": { + "description": "[%key:component::humidifier::common::condition_behavior_description%]", + "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "mode": { + "description": "The operation modes to check for.", + "name": "Mode" + } + }, + "name": "Humidifier is in mode" + }, "is_off": { "description": "Tests if one or more humidifiers are off.", "fields": { diff --git a/tests/components/humidifier/test_condition.py b/tests/components/humidifier/test_condition.py index 98e27a406a9..b45f8882964 100644 --- a/tests/components/humidifier/test_condition.py +++ b/tests/components/humidifier/test_condition.py @@ -1,16 +1,29 @@ """Test humidifier conditions.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from typing import Any import pytest +import voluptuous as vol +from homeassistant.components.humidifier.condition import CONF_MODE from homeassistant.components.humidifier.const import ( ATTR_ACTION, ATTR_HUMIDITY, HumidifierAction, + HumidifierEntityFeature, +) +from homeassistant.const import ( + ATTR_MODE, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITY_ID, + CONF_OPTIONS, + CONF_TARGET, + STATE_OFF, + STATE_ON, ) -from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import async_validate_condition_config from tests.components.common import ( ConditionStateDescription, @@ -39,6 +52,7 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]: "humidifier.is_on", "humidifier.is_drying", "humidifier.is_humidifying", + "humidifier.is_mode", "humidifier.is_target_humidity", ], ) @@ -153,6 +167,20 @@ async def test_humidifier_state_condition_behavior_all( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_condition_states_any( + condition="humidifier.is_mode", + condition_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + ), ], ) async def test_humidifier_attribute_condition_behavior_any( @@ -196,6 +224,20 @@ async def test_humidifier_attribute_condition_behavior_any( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_condition_states_all( + condition="humidifier.is_mode", + condition_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + ), ], ) async def test_humidifier_attribute_condition_behavior_all( @@ -291,3 +333,51 @@ async def test_humidifier_numerical_condition_behavior_all( condition_options=condition_options, states=states, ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition", "condition_options", "expected_result"), + [ + # Valid configurations + ( + "humidifier.is_mode", + {CONF_MODE: ["eco", "sleep"]}, + does_not_raise(), + ), + ( + "humidifier.is_mode", + {CONF_MODE: "eco"}, + does_not_raise(), + ), + # Invalid configurations + ( + "humidifier.is_mode", + # Empty mode list + {CONF_MODE: []}, + pytest.raises(vol.Invalid), + ), + ( + "humidifier.is_mode", + # Missing CONF_MODE + {}, + pytest.raises(vol.Invalid), + ), + ], +) +async def test_humidifier_is_mode_condition_validation( + hass: HomeAssistant, + condition: str, + condition_options: dict[str, Any], + expected_result: AbstractContextManager, +) -> None: + """Test humidifier is_mode condition config validation.""" + with expected_result: + await async_validate_condition_config( + hass, + { + "condition": condition, + CONF_TARGET: {CONF_ENTITY_ID: "humidifier.test"}, + CONF_OPTIONS: condition_options, + }, + ) From 72598479d5bf0563675a54f0af8e35fbebb727ad Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 26 Mar 2026 20:43:39 +0100 Subject: [PATCH 049/138] Update frontend to 20260325.1 (#166614) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index c7b933c5429..d43174468c8 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.0"] + "requirements": ["home-assistant-frontend==20260325.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6ae73ed686d..37e26f6f716 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.11.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.0 +home-assistant-frontend==20260325.1 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0ec4ae7bdf6..196d8711262 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.0 +home-assistant-frontend==20260325.1 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 420bdc5c085..bc354763045 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.0 +home-assistant-frontend==20260325.1 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From 1b972d4adcef3c9ccfbbcc1f190ae7a45b7ba75a Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Thu, 26 Mar 2026 11:47:39 -0700 Subject: [PATCH 050/138] Remove tplink_lte integration (#166615) --- .../components/tplink_lte/__init__.py | 172 ++---------------- .../components/tplink_lte/manifest.json | 3 +- homeassistant/components/tplink_lte/notify.py | 55 ------ .../components/tplink_lte/strings.json | 8 + requirements_all.txt | 3 - tests/components/tplink_lte/__init__.py | 1 + tests/components/tplink_lte/test_init.py | 23 +++ 7 files changed, 48 insertions(+), 217 deletions(-) delete mode 100644 homeassistant/components/tplink_lte/notify.py create mode 100644 homeassistant/components/tplink_lte/strings.json create mode 100644 tests/components/tplink_lte/__init__.py create mode 100644 tests/components/tplink_lte/test_init.py diff --git a/homeassistant/components/tplink_lte/__init__.py b/homeassistant/components/tplink_lte/__init__.py index ca9b8311ebe..9713a1aa227 100644 --- a/homeassistant/components/tplink_lte/__init__.py +++ b/homeassistant/components/tplink_lte/__init__.py @@ -1,172 +1,30 @@ -"""Support for TP-Link LTE modems.""" +"""The tplink_lte integration.""" -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -import aiohttp -import attr -import tp_connected import voluptuous as vol -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_RECIPIENT, - EVENT_HOMEASSISTANT_STOP, - Platform, -) -from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - DOMAIN = "tplink_lte" -DATA_KEY = "tplink_lte" - -CONF_NOTIFY = "notify" - -_NOTIFY_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_RECIPIENT): vol.All(cv.ensure_list, [cv.string]), - } -) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NOTIFY): vol.All( - cv.ensure_list, [_NOTIFY_SCHEMA] - ), - } - ) - ], - ) - }, + {DOMAIN: cv.match_all}, extra=vol.ALLOW_EXTRA, ) -@attr.s -class ModemData: - """Class for modem state.""" - - host: str = attr.ib() - modem: tp_connected.Modem = attr.ib() - - connected: bool = attr.ib(init=False, default=True) - - -@attr.s -class LTEData: - """Shared state.""" - - websession: aiohttp.ClientSession = attr.ib() - modem_data: dict[str, ModemData] = attr.ib(init=False, factory=dict) - - def get_modem_data(self, config: dict[str, Any]) -> ModemData | None: - """Get the requested or the only modem_data value.""" - if CONF_HOST in config: - return self.modem_data.get(config[CONF_HOST]) - if len(self.modem_data) == 1: - return next(iter(self.modem_data.values())) - - return None - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up TP-Link LTE component.""" - if DATA_KEY not in hass.data: - websession = async_create_clientsession( - hass, cookie_jar=aiohttp.CookieJar(unsafe=True) - ) - hass.data[DATA_KEY] = LTEData(websession) - - domain_config = config.get(DOMAIN, []) - - tasks = [_setup_lte(hass, conf) for conf in domain_config] - if tasks: - await asyncio.gather(*tasks) - - for conf in domain_config: - for notify_conf in conf.get(CONF_NOTIFY, []): - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.NOTIFY, DOMAIN, notify_conf, config - ) - ) - + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "ghsa_url": "https://github.com/advisories/GHSA-h95x-26f3-88hr", + }, + ) return True - - -async def _setup_lte( - hass: HomeAssistant, lte_config: dict[str, Any], delay: int = 0 -) -> None: - """Set up a TP-Link LTE modem.""" - - host: str = lte_config[CONF_HOST] - password: str = lte_config[CONF_PASSWORD] - - lte_data: LTEData = hass.data[DATA_KEY] - modem = tp_connected.Modem(hostname=host, websession=lte_data.websession) - - modem_data = ModemData(host, modem) - - try: - await _login(hass, modem_data, password) - except tp_connected.Error: - retry_task = hass.loop.create_task(_retry_login(hass, modem_data, password)) - - @callback - def cleanup_retry(event: Event) -> None: - """Clean up retry task resources.""" - if not retry_task.done(): - retry_task.cancel() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry) - - -async def _login(hass: HomeAssistant, modem_data: ModemData, password: str) -> None: - """Log in and complete setup.""" - await modem_data.modem.login(password=password) - modem_data.connected = True - lte_data: LTEData = hass.data[DATA_KEY] - lte_data.modem_data[modem_data.host] = modem_data - - async def cleanup(event: Event) -> None: - """Clean up resources.""" - await modem_data.modem.logout() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) - - -async def _retry_login( - hass: HomeAssistant, modem_data: ModemData, password: str -) -> None: - """Sleep and retry setup.""" - - _LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) - - modem_data.connected = False - delay = 15 - - while not modem_data.connected: - await asyncio.sleep(delay) - - try: - await _login(hass, modem_data, password) - _LOGGER.warning("Connected to %s", modem_data.host) - except tp_connected.Error: - delay = min(2 * delay, 300) diff --git a/homeassistant/components/tplink_lte/manifest.json b/homeassistant/components/tplink_lte/manifest.json index a880594e683..1f9057c3ad7 100644 --- a/homeassistant/components/tplink_lte/manifest.json +++ b/homeassistant/components/tplink_lte/manifest.json @@ -4,7 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/tplink_lte", "iot_class": "local_polling", - "loggers": ["tp_connected"], "quality_scale": "legacy", - "requirements": ["tp-connected==0.0.4"] + "requirements": [] } diff --git a/homeassistant/components/tplink_lte/notify.py b/homeassistant/components/tplink_lte/notify.py deleted file mode 100644 index 674f09efcd7..00000000000 --- a/homeassistant/components/tplink_lte/notify.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Support for TP-Link LTE notifications.""" - -from __future__ import annotations - -import logging -from typing import Any - -import attr -import tp_connected - -from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import CONF_RECIPIENT -from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from . import DATA_KEY, LTEData - -_LOGGER = logging.getLogger(__name__) - - -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> TplinkNotifyService | None: - """Get the notification service.""" - if discovery_info is None: - return None - return TplinkNotifyService(hass, discovery_info) - - -@attr.s -class TplinkNotifyService(BaseNotificationService): - """Implementation of a notification service.""" - - hass: HomeAssistant = attr.ib() - config: dict[str, Any] = attr.ib() - - async def async_send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to a user.""" - - lte_data: LTEData = self.hass.data[DATA_KEY] - modem_data = lte_data.get_modem_data(self.config) - if not modem_data: - _LOGGER.error("No modem available") - return - - phone = self.config[CONF_RECIPIENT] - targets = kwargs.get(ATTR_TARGET, phone) - if targets and message: - for target in targets: - try: - await modem_data.modem.sms(target, message) - except tp_connected.Error: - _LOGGER.error("Unable to send to %s", target) diff --git a/homeassistant/components/tplink_lte/strings.json b/homeassistant/components/tplink_lte/strings.json new file mode 100644 index 00000000000..d03b650746f --- /dev/null +++ b/homeassistant/components/tplink_lte/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "integration_removed": { + "description": "The TP-Link LTE integration has been removed from Home Assistant.\n\nThe integration has not been working since Home Assistant 2023.6.0, has no maintainer, and its underlying library depends on a package with a [critical security vulnerability]({ghsa_url}).\n\nTo resolve this issue, remove the `tplink_lte` configuration from your `configuration.yaml` file and restart Home Assistant.", + "title": "The TP-Link LTE integration has been removed" + } + } +} diff --git a/requirements_all.txt b/requirements_all.txt index 196d8711262..200e7bf25ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3138,9 +3138,6 @@ toonapi==0.3.0 # homeassistant.components.totalconnect total-connect-client==2025.12.2 -# homeassistant.components.tplink_lte -tp-connected==0.0.4 - # homeassistant.components.tplink_omada tplink-omada-client==1.5.6 diff --git a/tests/components/tplink_lte/__init__.py b/tests/components/tplink_lte/__init__.py new file mode 100644 index 00000000000..d487e0eba93 --- /dev/null +++ b/tests/components/tplink_lte/__init__.py @@ -0,0 +1 @@ +"""Tests for the TP-Link LTE integration.""" diff --git a/tests/components/tplink_lte/test_init.py b/tests/components/tplink_lte/test_init.py new file mode 100644 index 00000000000..76f65d83b8d --- /dev/null +++ b/tests/components/tplink_lte/test_init.py @@ -0,0 +1,23 @@ +"""Tests for the TP-Link LTE integration.""" + +from homeassistant.components.tplink_lte import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +async def test_tplink_lte_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the TP-Link LTE repair issue is created on setup.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: [{"host": "192.168.0.1", "password": "secret"}]}, + ) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + issue = issue_registry.async_get_issue(DOMAIN, DOMAIN) + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_key == "integration_removed" From ee9d9781ee8eccf23842c5c8136f3116efb3a4e7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:24:27 +0100 Subject: [PATCH 051/138] Add climate.is_hvac_mode condition (#166570) --- homeassistant/components/climate/condition.py | 41 ++++++++++++++++++- .../components/climate/conditions.yaml | 15 +++++++ homeassistant/components/climate/icons.json | 3 ++ homeassistant/components/climate/strings.json | 14 +++++++ tests/components/climate/test_condition.py | 37 +++++++++++++++++ 5 files changed, 109 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py index 8279b9bf583..0d1b5803b59 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -1,10 +1,18 @@ """Provides conditions for climates.""" -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, + ConditionConfig, + EntityConditionBase, EntityNumericalConditionWithUnitBase, make_entity_numerical_condition, make_entity_state_condition, @@ -13,6 +21,36 @@ from homeassistant.util.unit_conversion import TemperatureConverter from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode +CONF_HVAC_MODE = "hvac_mode" + +_HVAC_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_HVAC_MODE): vol.All( + cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)] + ), + }, + } +) + + +class ClimateHVACModeCondition(EntityConditionBase): + """Condition for climate HVAC mode.""" + + _domain_specs = {DOMAIN: DomainSpec()} + _schema = _HVAC_MODE_CONDITION_SCHEMA + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize the HVAC mode condition.""" + super().__init__(hass, config) + if TYPE_CHECKING: + assert config.options is not None + self._hvac_modes: set[str] = set(config.options[CONF_HVAC_MODE]) + + def is_valid_state(self, entity_state: State) -> bool: + """Check if the state matches any of the expected HVAC modes.""" + return entity_state.state in self._hvac_modes + class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase): """Mixin for climate target temperature conditions with unit conversion.""" @@ -28,6 +66,7 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase): CONDITIONS: dict[str, type[Condition]] = { + "is_hvac_mode": ClimateHVACModeCondition, "is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF), "is_on": make_entity_state_condition( DOMAIN, diff --git a/homeassistant/components/climate/conditions.yaml b/homeassistant/components/climate/conditions.yaml index 771d5e96332..cb1e09abac0 100644 --- a/homeassistant/components/climate/conditions.yaml +++ b/homeassistant/components/climate/conditions.yaml @@ -45,6 +45,21 @@ is_cooling: *condition_common is_drying: *condition_common is_heating: *condition_common +is_hvac_mode: + target: *condition_climate_target + fields: + behavior: *condition_behavior + hvac_mode: + context: + filter_target: target + required: true + selector: + state: + hide_states: + - unavailable + - unknown + multiple: true + target_humidity: target: *condition_climate_target fields: diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index 3300deb17e9..b88d4ba63f2 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -9,6 +9,9 @@ "is_heating": { "condition": "mdi:fire" }, + "is_hvac_mode": { + "condition": "mdi:thermostat" + }, "is_off": { "condition": "mdi:power-off" }, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index ec6c99e51ab..7fc608ff419 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -41,6 +41,20 @@ }, "name": "Climate-control device is heating" }, + "is_hvac_mode": { + "description": "Tests if one or more climate-control devices are set to a specific HVAC mode.", + "fields": { + "behavior": { + "description": "[%key:component::climate::common::condition_behavior_description%]", + "name": "[%key:component::climate::common::condition_behavior_name%]" + }, + "hvac_mode": { + "description": "The HVAC modes to test for.", + "name": "Modes" + } + }, + "name": "Climate-control device HVAC mode" + }, "is_off": { "description": "Tests if one or more climate-control devices are off.", "fields": { diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 2d1305b4850..13bf598241a 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -47,6 +47,7 @@ async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]: "climate.is_cooling", "climate.is_drying", "climate.is_heating", + "climate.is_hvac_mode", "climate.target_humidity", "climate.target_temperature", ], @@ -83,6 +84,24 @@ async def test_climate_conditions_gated_by_labs_flag( ], other_states=[HVACMode.OFF], ), + *( + param + for mode in HVACMode + for param in parametrize_condition_states_any( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [mode]}, + target_states=[mode], + other_states=[m for m in HVACMode if m != mode], + ) + ), + *parametrize_condition_states_any( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [HVACMode.HEAT, HVACMode.COOL]}, + target_states=[HVACMode.HEAT, HVACMode.COOL], + other_states=[ + m for m in HVACMode if m not in (HVACMode.HEAT, HVACMode.COOL) + ], + ), ], ) async def test_climate_state_condition_behavior_any( @@ -133,6 +152,24 @@ async def test_climate_state_condition_behavior_any( ], other_states=[HVACMode.OFF], ), + *( + param + for mode in HVACMode + for param in parametrize_condition_states_all( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [mode]}, + target_states=[mode], + other_states=[m for m in HVACMode if m != mode], + ) + ), + *parametrize_condition_states_all( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [HVACMode.HEAT, HVACMode.COOL]}, + target_states=[HVACMode.HEAT, HVACMode.COOL], + other_states=[ + m for m in HVACMode if m not in (HVACMode.HEAT, HVACMode.COOL) + ], + ), ], ) async def test_climate_state_condition_behavior_all( From cff4cf4d2c59e63d98d0998baa5800dceead20f2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Mar 2026 19:51:36 +0000 Subject: [PATCH 052/138] Bump version to 2026.4.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b285564c8f7..308887b98fd 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index ca17ff3f791..7e2409cc226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0b2" +version = "2026.4.0b3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 9c1f9ca5c60845362e88e704c7e21b54f620c71a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 07:48:14 +0100 Subject: [PATCH 053/138] Add weather support to humidity conditions (#166599) --- .../components/humidity/condition.py | 7 ++ .../components/humidity/conditions.yaml | 1 + tests/components/humidity/test_condition.py | 79 +++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/homeassistant/components/humidity/condition.py b/homeassistant/components/humidity/condition.py index 6a990837b0c..101815a4009 100644 --- a/homeassistant/components/humidity/condition.py +++ b/homeassistant/components/humidity/condition.py @@ -11,6 +11,10 @@ from homeassistant.components.humidifier import ( DOMAIN as HUMIDIFIER_DOMAIN, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.components.weather import ( + ATTR_WEATHER_HUMIDITY, + DOMAIN as WEATHER_DOMAIN, +) from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec @@ -24,6 +28,9 @@ HUMIDITY_DOMAIN_SPECS = { value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.HUMIDITY), + WEATHER_DOMAIN: DomainSpec( + value_source=ATTR_WEATHER_HUMIDITY, + ), } CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/humidity/conditions.yaml b/homeassistant/components/humidity/conditions.yaml index 2f518db77d8..06818a57974 100644 --- a/homeassistant/components/humidity/conditions.yaml +++ b/homeassistant/components/humidity/conditions.yaml @@ -19,6 +19,7 @@ is_value: device_class: humidity - domain: climate - domain: humidifier + - domain: weather fields: behavior: required: true diff --git a/tests/components/humidity/test_condition.py b/tests/components/humidity/test_condition.py index e71bbf7ded1..f878dfe14a0 100644 --- a/tests/components/humidity/test_condition.py +++ b/tests/components/humidity/test_condition.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ( from homeassistant.components.humidifier import ( ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ) +from homeassistant.components.weather import ATTR_WEATHER_HUMIDITY from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON from homeassistant.core import HomeAssistant @@ -48,6 +49,12 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "humidifier") +@pytest.fixture +async def target_weathers(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple weather entities associated with different targets.""" + return await target_entities(hass, "weather") + + @pytest.mark.parametrize( "condition", [ @@ -275,3 +282,75 @@ async def test_humidity_humidifier_condition_behavior_all( condition_options=condition_options, states=states, ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("weather"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_numerical_attribute_condition_above_below_any( + "humidity.is_value", + "sunny", + ATTR_WEATHER_HUMIDITY, + ), +) +async def test_humidity_weather_condition_behavior_any( + hass: HomeAssistant, + target_weathers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the humidity weather condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_weathers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("weather"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_numerical_attribute_condition_above_below_all( + "humidity.is_value", + "sunny", + ATTR_WEATHER_HUMIDITY, + ), +) +async def test_humidity_weather_condition_behavior_all( + hass: HomeAssistant, + target_weathers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the humidity weather condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_weathers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) From 4ee3177c5dc873af20ad6c1d377f58e76c7b2c36 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 10:48:20 +0100 Subject: [PATCH 054/138] Add select conditions (#166612) --- .../components/automation/__init__.py | 1 + homeassistant/components/select/condition.py | 55 ++++ .../components/select/conditions.yaml | 26 ++ homeassistant/components/select/icons.json | 5 + homeassistant/components/select/strings.json | 24 ++ tests/components/select/test_condition.py | 284 ++++++++++++++++++ 6 files changed, 395 insertions(+) create mode 100644 homeassistant/components/select/condition.py create mode 100644 homeassistant/components/select/conditions.yaml create mode 100644 tests/components/select/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 8d4fc2ebc12..787a3db179d 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -142,6 +142,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "person", "power", "schedule", + "select", "siren", "switch", "temperature", diff --git a/homeassistant/components/select/condition.py b/homeassistant/components/select/condition.py new file mode 100644 index 00000000000..c04f9fc4484 --- /dev/null +++ b/homeassistant/components/select/condition.py @@ -0,0 +1,55 @@ +"""Provides conditions for selects.""" + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.components.input_select import DOMAIN as INPUT_SELECT_DOMAIN +from homeassistant.const import CONF_OPTIONS +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import ( + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, + Condition, + ConditionConfig, + EntityStateConditionBase, +) + +from .const import CONF_OPTION, DOMAIN + +IS_OPTION_SELECTED_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_OPTION): vol.All( + cv.ensure_list, vol.Length(min=1), [str] + ), + }, + } +) + +SELECT_DOMAIN_SPECS = {DOMAIN: DomainSpec(), INPUT_SELECT_DOMAIN: DomainSpec()} + + +class IsOptionSelectedCondition(EntityStateConditionBase): + """Condition for select option.""" + + _domain_specs = SELECT_DOMAIN_SPECS + _schema = IS_OPTION_SELECTED_SCHEMA + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize the option selected condition.""" + super().__init__(hass, config) + if TYPE_CHECKING: + assert config.options is not None + self._states = set(config.options[CONF_OPTION]) + + +CONDITIONS: dict[str, type[Condition]] = { + "is_option_selected": IsOptionSelectedCondition, +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the select conditions.""" + return CONDITIONS diff --git a/homeassistant/components/select/conditions.yaml b/homeassistant/components/select/conditions.yaml new file mode 100644 index 00000000000..bc1feaccbf4 --- /dev/null +++ b/homeassistant/components/select/conditions.yaml @@ -0,0 +1,26 @@ +is_option_selected: + target: + entity: + - domain: select + - domain: input_select + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + option: + context: + filter_target: target + required: true + selector: + state: + attribute: options + hide_states: + - unavailable + - unknown + multiple: true diff --git a/homeassistant/components/select/icons.json b/homeassistant/components/select/icons.json index 84f61242bd2..9a050298240 100644 --- a/homeassistant/components/select/icons.json +++ b/homeassistant/components/select/icons.json @@ -1,4 +1,9 @@ { + "conditions": { + "is_option_selected": { + "condition": "mdi:format-list-bulleted" + } + }, "entity_component": { "_": { "default": "mdi:format-list-bulleted" diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index bf394a6c30d..cac07327f53 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -1,4 +1,20 @@ { + "conditions": { + "is_option_selected": { + "description": "Tests if one or more dropdowns have a specific option selected.", + "fields": { + "behavior": { + "description": "Whether the condition should pass when any or all targeted entities match.", + "name": "Behavior" + }, + "option": { + "description": "The options to check for.", + "name": "Option" + } + }, + "name": "Option is selected" + } + }, "device_automation": { "action_type": { "select_first": "Change {entity_name} to first option", @@ -36,6 +52,14 @@ "message": "Option {option} is not valid for entity {entity_id}, valid options are: {options}." } }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + } + }, "services": { "select_first": { "description": "Selects the first option of a select.", diff --git a/tests/components/select/test_condition.py b/tests/components/select/test_condition.py new file mode 100644 index 00000000000..edd97c41ee2 --- /dev/null +++ b/tests/components/select/test_condition.py @@ -0,0 +1,284 @@ +"""Test select conditions.""" + +from contextlib import AbstractContextManager, nullcontext as does_not_raise +from typing import Any + +import pytest +import voluptuous as vol + +from homeassistant.components.select.condition import CONF_OPTION +from homeassistant.const import CONF_ENTITY_ID, CONF_OPTIONS, CONF_TARGET +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import async_validate_condition_config + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + create_target_condition, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_selects(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple select entities associated with different targets.""" + return await target_entities(hass, "select") + + +@pytest.fixture +async def target_input_selects(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple input_select entities associated with different targets.""" + return await target_entities(hass, "input_select") + + +@pytest.mark.parametrize( + "condition", + ["select.is_option_selected"], +) +async def test_select_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the select conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("select"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_condition_states_any( + condition="select.is_option_selected", + condition_options={CONF_OPTION: ["option_a", "option_b"]}, + target_states=["option_a", "option_b"], + other_states=["option_c"], + ), +) +async def test_select_condition_behavior_any( + hass: HomeAssistant, + target_selects: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the select condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_selects, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("select"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_condition_states_all( + condition="select.is_option_selected", + condition_options={CONF_OPTION: ["option_a", "option_b"]}, + target_states=["option_a", "option_b"], + other_states=["option_c"], + ), +) +async def test_select_condition_behavior_all( + hass: HomeAssistant, + target_selects: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the select condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_selects, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("input_select"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_condition_states_any( + condition="select.is_option_selected", + condition_options={CONF_OPTION: ["option_a", "option_b"]}, + target_states=["option_a", "option_b"], + other_states=["option_c"], + ), +) +async def test_input_select_condition_behavior_any( + hass: HomeAssistant, + target_input_selects: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the select condition with input_select entities and 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_input_selects, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("input_select"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_condition_states_all( + condition="select.is_option_selected", + condition_options={CONF_OPTION: ["option_a", "option_b"]}, + target_states=["option_a", "option_b"], + other_states=["option_c"], + ), +) +async def test_input_select_condition_behavior_all( + hass: HomeAssistant, + target_input_selects: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the select condition with input_select entities and 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_input_selects, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +# --- Cross-domain test --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_select_condition_evaluates_both_domains( + hass: HomeAssistant, +) -> None: + """Test that the select condition evaluates both select and input_select entities.""" + entity_id_select = "select.test_select" + entity_id_input_select = "input_select.test_input_select" + + hass.states.async_set(entity_id_select, "option_a") + hass.states.async_set(entity_id_input_select, "option_a") + await hass.async_block_till_done() + + cond = await create_target_condition( + hass, + condition="select.is_option_selected", + target={CONF_ENTITY_ID: [entity_id_select, entity_id_input_select]}, + behavior="any", + condition_options={CONF_OPTION: ["option_a", "option_b"]}, + ) + + assert cond(hass) is True + + # Set one to a non-matching option - "any" behavior should still pass + hass.states.async_set(entity_id_select, "option_c") + await hass.async_block_till_done() + + assert cond(hass) is True + + # Set both to non-matching options + hass.states.async_set(entity_id_input_select, "option_c") + await hass.async_block_till_done() + + assert cond(hass) is False + + +# --- Schema validation tests --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition", "condition_options", "expected_result"), + [ + # Valid configurations + ( + "select.is_option_selected", + {CONF_OPTION: ["option_a", "option_b"]}, + does_not_raise(), + ), + ( + "select.is_option_selected", + {CONF_OPTION: "option_a"}, + does_not_raise(), + ), + # Invalid configurations + ( + "select.is_option_selected", + # Empty option list + {CONF_OPTION: []}, + pytest.raises(vol.Invalid), + ), + ( + "select.is_option_selected", + # Missing CONF_OPTION + {}, + pytest.raises(vol.Invalid), + ), + ], +) +async def test_select_is_option_selected_condition_validation( + hass: HomeAssistant, + condition: str, + condition_options: dict[str, Any], + expected_result: AbstractContextManager, +) -> None: + """Test select is_option_selected condition config validation.""" + with expected_result: + await async_validate_condition_config( + hass, + { + "condition": condition, + CONF_TARGET: {CONF_ENTITY_ID: "select.test"}, + CONF_OPTIONS: condition_options, + }, + ) From 7f94f95ac9ce75b7de6cd975a9c3ac86172155d6 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:51:50 +0100 Subject: [PATCH 055/138] Wait for device registry in entity registry loading (#166636) --- homeassistant/bootstrap.py | 1 + homeassistant/helpers/device_registry.py | 27 +++++++++++++- homeassistant/helpers/entity_registry.py | 4 +++ homeassistant/scripts/auth.py | 1 + homeassistant/scripts/check_config.py | 1 + tests/common.py | 2 ++ tests/helpers/test_device_registry.py | 13 +++++++ tests/helpers/test_entity_registry.py | 45 ++++++++++++++++++++++++ 8 files changed, 93 insertions(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 803ae756018..ce411280772 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -468,6 +468,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> bool: translation.async_setup(hass) recovery = hass.config.recovery_mode + device_registry.async_setup(hass) try: await asyncio.gather( create_eager_task(get_internal_store_manager(hass).async_initialize()), diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index c8b384189df..dc2f083c90e 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections import defaultdict from collections.abc import Iterable, Mapping from datetime import datetime @@ -771,6 +772,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): devices: ActiveDeviceRegistryItems deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] _device_data: dict[str, DeviceEntry] + _loaded_event: asyncio.Event | None = None def __init__(self, hass: HomeAssistant) -> None: """Initialize the device registry.""" @@ -784,6 +786,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): serialize_in_event_loop=False, ) + @callback + def async_setup(self) -> None: + """Set up the registry.""" + self._loaded_event = asyncio.Event() + @callback def async_get(self, device_id: str) -> DeviceEntry | None: """Get device. @@ -1463,6 +1470,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): async def _async_load(self) -> None: """Load the device registry.""" + assert self._loaded_event is not None + assert not self._loaded_event.is_set() + async_setup_cleanup(self.hass, self) data = await self._store.async_load() @@ -1560,6 +1570,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self.deleted_devices = deleted_devices self._device_data = devices.data + self._loaded_event.set() + + async def async_wait_loaded(self) -> None: + """Wait until the device registry is fully loaded. + + Will only wait if the registry had already been set up. + """ + if self._loaded_event is not None: + await self._loaded_event.wait() + @callback def _data_to_save(self) -> dict[str, Any]: """Return data of device registry to store in a file.""" @@ -1706,9 +1726,14 @@ def async_get(hass: HomeAssistant) -> DeviceRegistry: return DeviceRegistry(hass) +def async_setup(hass: HomeAssistant) -> None: + """Set up device registry.""" + assert DATA_REGISTRY not in hass.data + async_get(hass).async_setup() + + async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: """Load device registry.""" - assert DATA_REGISTRY not in hass.data await async_get(hass).async_load(load_empty=load_empty) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 0cbcf22b897..33276acfafd 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1944,6 +1944,10 @@ class EntityRegistry(BaseRegistry): async def _async_load(self) -> None: """Load the entity registry.""" + # Device registry must be loaded before entity registry because + # migration and entity processing reference device names. + await dr.async_get(self.hass).async_wait_loaded() + _async_setup_cleanup(self.hass, self) _async_setup_entity_restore(self.hass, self) diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index b734d898b64..8ca2ef7fef1 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -55,6 +55,7 @@ def run(args: Sequence[str] | None) -> None: async def run_command(args: argparse.Namespace) -> None: """Run the command.""" hass = HomeAssistant(os.path.join(os.getcwd(), args.config)) + dr.async_setup(hass) await asyncio.gather(dr.async_load(hass), er.async_load(hass)) hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) provider = hass.auth.auth_providers[0] diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index c5a77532822..ba883775f42 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -302,6 +302,7 @@ async def async_check_config(config_dir): hass = core.HomeAssistant(config_dir) loader.async_setup(hass) hass.config_entries = ConfigEntries(hass, {}) + dr.async_setup(hass) await ar.async_load(hass) await dr.async_load(hass) await er.async_load(hass) diff --git a/tests/common.py b/tests/common.py index 2e1a9f3fe14..30f998034e1 100644 --- a/tests/common.py +++ b/tests/common.py @@ -305,6 +305,8 @@ async def async_test_home_assistant( hass ) if load_registries: + dr.async_setup(hass) + with ( patch.object(StoreWithoutWriteLoad, "async_load", return_value=None), patch( diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index a3490da9514..b184525c47f 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -363,6 +363,7 @@ async def test_loading_from_storage( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) assert len(registry.devices) == 1 @@ -500,6 +501,7 @@ async def test_migration_from_1_1( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -654,6 +656,7 @@ async def test_migration_from_1_2( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -790,6 +793,7 @@ async def test_migration_fom_1_3( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -928,6 +932,7 @@ async def test_migration_from_1_4( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -1068,6 +1073,7 @@ async def test_migration_from_1_5( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -1210,6 +1216,7 @@ async def test_migration_from_1_6( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -1354,6 +1361,7 @@ async def test_migration_from_1_7( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -1496,6 +1504,7 @@ async def test_migration_from_1_10( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -1632,6 +1641,7 @@ async def test_migration_from_1_11( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -2627,6 +2637,7 @@ async def test_loading_saving_data( # Now load written data in new registry registry2 = dr.DeviceRegistry(hass) await flush_store(device_registry._store) + registry2.async_setup() await registry2.async_load() # Ensure same order @@ -3782,6 +3793,7 @@ async def test_cleanup_entity_registry_change( Don't pre-load the registries as the debouncer will then not be waiting for EVENT_ENTITY_REGISTRY_UPDATED events. """ + dr.async_setup(hass) await dr.async_load(hass) await er.async_load(hass) dev_reg = dr.async_get(hass) @@ -4943,6 +4955,7 @@ async def test_loading_invalid_configuration_url_from_storage( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) assert len(registry.devices) == 1 diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 4ae9637a878..b26a7d23d47 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1,5 +1,6 @@ """Tests for the Entity Registry.""" +import asyncio from datetime import datetime, timedelta from functools import partial from typing import Any @@ -504,6 +505,49 @@ async def test_loading_saving_data( assert new_entry2.unit_of_measurement == "initial-unit_of_measurement" +@pytest.mark.parametrize("load_registries", [False]) +async def test_entity_registry_loading_waits_for_device_registry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test entity registry waits for device registry when loaded concurrently. + + Both registries are loaded in parallel during bootstrap via asyncio.gather. + The entity registry accesses device registry during loading. This test delays + the device registry store load so entity registry attempts to load first. + """ + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "test.my_entity", + "device_id": "some-device", + "platform": "test_platform", + "unique_id": "unique-1", + }, + ] + }, + } + + original_load = dr.DeviceRegistryStore.async_load + + async def delayed_load(self: dr.DeviceRegistryStore) -> Any: + await asyncio.sleep(0) + return await original_load(self) + + dr.async_setup(hass) + + with patch.object(dr.DeviceRegistryStore, "async_load", delayed_load): + await asyncio.gather( + er.async_load(hass), + dr.async_load(hass), + ) + + registry = er.async_get(hass) + assert registry.async_get("test.my_entity") is not None + + def test_get_available_entity_id_considers_registered_entities( entity_registry: er.EntityRegistry, ) -> None: @@ -1547,6 +1591,7 @@ async def test_migration_1_20( "deleted_devices": [], }, } + dr.async_setup(hass) await dr.async_load(hass) # Entity registry data at version 1.20 From 71981f66ec84cbc4a66a90dc4720e85fecf24b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 27 Mar 2026 11:05:58 +0000 Subject: [PATCH 056/138] Update idasen-ha to 2.6.5 (#166645) --- homeassistant/components/idasen_desk/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 9ed01149844..1acaf083485 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -13,5 +13,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["idasen-ha==2.6.4"] + "requirements": ["idasen-ha==2.6.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 200e7bf25ef..bbec79a9fbd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1286,7 +1286,7 @@ icalendar==6.3.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.6.4 +idasen-ha==2.6.5 # homeassistant.components.idrive_e2 idrive-e2-client==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc354763045..016ba51dda4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1144,7 +1144,7 @@ icalendar==6.3.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.6.4 +idasen-ha==2.6.5 # homeassistant.components.idrive_e2 idrive-e2-client==0.1.1 From 954926a05c7a6a1f02169e4bca76aa48d6a6b339 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 27 Mar 2026 08:51:39 +0100 Subject: [PATCH 057/138] Bump aioamazondevices to 13.3.1 (#166658) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 03303292822..0401bb3828e 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.3.0"] + "requirements": ["aioamazondevices==13.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index bbec79a9fbd..07f87fc3979 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.3.0 +aioamazondevices==13.3.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 016ba51dda4..8163e2f8c24 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.3.0 +aioamazondevices==13.3.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 6b7693b2fd01b1ed89159e7363be378d05371776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Fri, 27 Mar 2026 16:14:35 +0100 Subject: [PATCH 058/138] Add missing miele program_id code (#166685) --- homeassistant/components/miele/const.py | 1 + homeassistant/components/miele/strings.json | 1 + tests/components/miele/snapshots/test_sensor.ambr | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 24e4b928059..faf6f626f73 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -452,6 +452,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True): proofing = 27, 10057 sportswear = 29, 10052 automatic_plus = 31 + table_linen = 33 outerwear = 37 pillows = 39 cool_air = 45 # washer-dryer diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index b0b0dd1239c..16f10887278 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -881,6 +881,7 @@ "swiss_roll": "Swiss roll", "swiss_toffee_cream_100_ml": "Swiss toffee cream (100 ml)", "swiss_toffee_cream_150_ml": "Swiss toffee cream (150 ml)", + "table_linen": "Table linen", "tagliatelli_fresh": "Tagliatelli (fresh)", "tall_items": "Tall items", "tart_flambe": "Tart flambè", diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index afdbc97ccbd..c3dc8c876f3 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -7624,6 +7624,7 @@ 'starch', 'steam_care', 'stuffed_toys', + 'table_linen', 'trainers', 'trainers_refresh', 'warm_air', @@ -7707,6 +7708,7 @@ 'starch', 'steam_care', 'stuffed_toys', + 'table_linen', 'trainers', 'trainers_refresh', 'warm_air', @@ -11422,6 +11424,7 @@ 'starch', 'steam_care', 'stuffed_toys', + 'table_linen', 'trainers', 'trainers_refresh', 'warm_air', @@ -11505,6 +11508,7 @@ 'starch', 'steam_care', 'stuffed_toys', + 'table_linen', 'trainers', 'trainers_refresh', 'warm_air', From bbae0862b06f2596be88a45e212114244ceb3f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Fri, 27 Mar 2026 15:02:04 +0100 Subject: [PATCH 059/138] Add missing miele oven codes (#166690) --- homeassistant/components/miele/const.py | 3 +++ homeassistant/components/miele/strings.json | 2 ++ tests/components/miele/snapshots/test_sensor.ambr | 12 ++++++++++++ 3 files changed, 17 insertions(+) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index faf6f626f73..2f8215767fc 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -270,6 +270,7 @@ class ProgramPhaseOven(MieleEnum, missing_to_none=True): process_finished = 3078 searing = 3080 roasting = 3081 + cooling_down = 3083 energy_save = 3084 pre_heating = 3099 @@ -587,6 +588,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True): microwave_fan_grill = 23 conventional_heat = 24 top_heat = 25 + booster = 27 fan_grill = 29 bottom_heat = 31 moisture_plus_auto_roast = 35, 48 @@ -595,6 +597,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True): moisture_plus_conventional_heat = 51, 76 popcorn = 53 quick_microwave = 54 + airfry = 95 custom_program_1 = 97 custom_program_2 = 98 custom_program_3 = 99 diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 16f10887278..8ce6cc1b81d 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -273,6 +273,7 @@ "program_id": { "name": "Program", "state": { + "airfry": "AirFry", "almond_macaroons_1_tray": "Almond macaroons (1 tray)", "almond_macaroons_2_trays": "Almond macaroons (2 trays)", "amaranth": "Amaranth", @@ -334,6 +335,7 @@ "blanching": "Blanching", "blueberry_muffins": "Blueberry muffins", "bologna_sausage": "Bologna sausage", + "booster": "Booster", "bottling": "Bottling", "bottling_hard": "Bottling (hard)", "bottling_medium": "Bottling (medium)", diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index c3dc8c876f3..b1bc36daedd 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -5243,6 +5243,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'airfry', 'almond_macaroons_1_tray', 'almond_macaroons_2_trays', 'amaranth', @@ -5294,6 +5295,7 @@ 'blanching', 'blueberry_muffins', 'bologna_sausage', + 'booster', 'bottling', 'bottling_hard', 'bottling_medium', @@ -5856,6 +5858,7 @@ 'device_class': 'enum', 'friendly_name': 'Oven Program', 'options': list([ + 'airfry', 'almond_macaroons_1_tray', 'almond_macaroons_2_trays', 'amaranth', @@ -5907,6 +5910,7 @@ 'blanching', 'blueberry_muffins', 'bologna_sausage', + 'booster', 'bottling', 'bottling_hard', 'bottling_medium', @@ -6449,6 +6453,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'cooling_down', 'energy_save', 'heating_up', 'not_running', @@ -6495,6 +6500,7 @@ 'device_class': 'enum', 'friendly_name': 'Oven Program phase', 'options': list([ + 'cooling_down', 'energy_save', 'heating_up', 'not_running', @@ -9043,6 +9049,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'airfry', 'almond_macaroons_1_tray', 'almond_macaroons_2_trays', 'amaranth', @@ -9094,6 +9101,7 @@ 'blanching', 'blueberry_muffins', 'bologna_sausage', + 'booster', 'bottling', 'bottling_hard', 'bottling_medium', @@ -9656,6 +9664,7 @@ 'device_class': 'enum', 'friendly_name': 'Oven Program', 'options': list([ + 'airfry', 'almond_macaroons_1_tray', 'almond_macaroons_2_trays', 'amaranth', @@ -9707,6 +9716,7 @@ 'blanching', 'blueberry_muffins', 'bologna_sausage', + 'booster', 'bottling', 'bottling_hard', 'bottling_medium', @@ -10249,6 +10259,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'cooling_down', 'energy_save', 'heating_up', 'not_running', @@ -10295,6 +10306,7 @@ 'device_class': 'enum', 'friendly_name': 'Oven Program phase', 'options': list([ + 'cooling_down', 'energy_save', 'heating_up', 'not_running', From 754291b34fbeb12c8b788597f99824c4e452dbe2 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:45:39 +0100 Subject: [PATCH 060/138] Use legacy naming for entities (#166696) --- homeassistant/components/diagnostics/util.py | 1 - homeassistant/helpers/entity_registry.py | 96 ++-- tests/components/zwave_js/test_init.py | 10 +- tests/helpers/test_entity_registry.py | 550 +++++-------------- tests/syrupy.py | 1 - 5 files changed, 174 insertions(+), 484 deletions(-) diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index c40b38c6de1..9b07fbd2d14 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -51,7 +51,6 @@ def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool: return a.name not in ( "_cache", "compat_aliases", - "compat_name", "original_name_unprefixed", ) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 33276acfafd..851ab2c8990 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -80,7 +80,7 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 21 +STORAGE_VERSION_MINOR = 22 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 @@ -240,7 +240,6 @@ class RegistryEntry: # For backwards compatibility, should be removed in the future compat_aliases: list[str] = attr.ib(factory=list, eq=False) - compat_name: str | None = attr.ib(default=None, eq=False) # original_name_unprefixed is used to store the result of stripping # the device name prefix from the original_name, if possible. @@ -413,8 +412,7 @@ class RegistryEntry: "has_entity_name": self.has_entity_name, "labels": list(self.labels), "modified_at": self.modified_at, - "name": self.compat_name, - "name_v2": self.name, + "name": self.name, "object_id_base": self.object_id_base, "options": self.options, "original_device_class": self.original_device_class, @@ -471,6 +469,7 @@ def _async_get_full_entity_name( original_name: str | None, original_name_unprefixed: str | None | UndefinedType = UNDEFINED, overridden_name: str | None = None, + use_legacy_naming: bool = False, ) -> str: """Get full name for an entity. @@ -480,7 +479,7 @@ def _async_get_full_entity_name( if name is None and overridden_name is not None: name = overridden_name - else: + elif not use_legacy_naming or name is None: device_name: str | None = None if ( device_id is not None @@ -533,6 +532,7 @@ def async_get_full_entity_name( name=entry.name, original_name=original_name, original_name_unprefixed=original_name_unprefixed, + use_legacy_naming=True, ) @@ -660,7 +660,6 @@ class DeletedRegistryEntry: # For backwards compatibility, should be removed in the future compat_aliases: list[str] = attr.ib(factory=list, eq=False) - compat_name: str | None = attr.ib(default=None, eq=False) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @@ -696,8 +695,7 @@ class DeletedRegistryEntry: "id": self.id, "labels": list(self.labels), "modified_at": self.modified_at, - "name": self.compat_name, - "name_v2": self.name, + "name": self.name, "options": self.options if self.options is not UNDEFINED else {}, "options_undefined": self.options is UNDEFINED, "orphaned_timestamp": self.orphaned_timestamp, @@ -850,46 +848,37 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): for entity in data["entities"]: entity["object_id_base"] = entity["original_name"] - if old_minor_version < 21: - # Version 1.21 migrates the full name to include device name, - # even if entity name is overwritten by user. - # It also adds support for COMPUTED_NAME in aliases and starts preserving their order. - # To avoid a major version bump, we keep the old name and aliases as-is - # and use new name_v2 and aliases_v2 fields instead. + if old_minor_version == 21: + # Version 1.21 has been reverted. + # It migrated entity names to the new format stored in `name_v2` + # field, automatically stripping any device name prefix present. + # The old name was stored in `name` field for backwards compatibility. + # For users who already migrated to v1.21, we restore old names + # but try to preserve any user renames made since that migration. device_registry = dr.async_get(self.hass) for entity in data["entities"]: - alias_to_add: str | None = None + old_name = entity["name"] + name = entity.pop("name_v2") if ( - (name := entity["name"]) + (name != old_name) and (device_id := entity["device_id"]) is not None and (device := device_registry.async_get(device_id)) is not None and (device_name := device.name_by_user or device.name) ): - # Strip the device name prefix from the entity name if present, - # and add the full generated name as an alias. - # If the name doesn't have the device name prefix and the - # entity is exposed to a voice assistant, add the previous - # name as an alias instead to preserve backwards compatibility. - if ( - new_name := _async_strip_prefix_from_entity_name( - name, device_name - ) - ) is not None: - name = new_name - elif any( - entity.get("options", {}).get(key, {}).get("should_expose") - for key in ("conversation", "cloud.google_assistant") - ): - alias_to_add = name + name = f"{device_name} {name}" - entity["name_v2"] = name - entity["aliases_v2"] = [alias_to_add, *entity["aliases"]] + entity["name"] = name + + if old_minor_version < 22: + # Version 1.22 adds support for COMPUTED_NAME in aliases and starts preserving + # their order. + # To avoid a major version bump, we keep the old aliases as-is and use aliases_v2 + # field instead. + for entity in data["entities"]: + entity["aliases_v2"] = [None, *entity["aliases"]] for entity in data["deleted_entities"]: - # We don't know what the device name was, so the only thing we can do - # is to clear the overwritten name to not mislead users. - entity["name_v2"] = None entity["aliases_v2"] = [None, *entity["aliases"]] if old_major_version > 1: @@ -1363,7 +1352,6 @@ class EntityRegistry(BaseRegistry): area_id = deleted_entity.area_id categories = deleted_entity.categories compat_aliases = deleted_entity.compat_aliases - compat_name = deleted_entity.compat_name created_at = deleted_entity.created_at device_class = deleted_entity.device_class if deleted_entity.disabled_by is not UNDEFINED: @@ -1395,7 +1383,6 @@ class EntityRegistry(BaseRegistry): area_id = None categories = {} compat_aliases = [] - compat_name = None device_class = None icon = None labels = set() @@ -1443,7 +1430,6 @@ class EntityRegistry(BaseRegistry): categories=categories, capabilities=none_if_undefined(capabilities), compat_aliases=compat_aliases, - compat_name=compat_name, config_entry_id=none_if_undefined(config_entry_id), config_subentry_id=none_if_undefined(config_subentry_id), created_at=created_at, @@ -1506,7 +1492,6 @@ class EntityRegistry(BaseRegistry): area_id=entity.area_id, categories=entity.categories, compat_aliases=entity.compat_aliases, - compat_name=entity.compat_name, config_entry_id=config_entry_id, config_subentry_id=entity.config_subentry_id, created_at=entity.created_at, @@ -1620,14 +1605,27 @@ class EntityRegistry(BaseRegistry): for entity in entities: if entity.has_entity_name: continue - name = ( - entity.original_name_unprefixed - if by_user and entity.name is None - else UNDEFINED - ) + + # When a user renames a device, update entity names to reflect + # the new device name. + # An empty name_unprefixed means the entity name equals + # the device name (e.g. a main sensor); a non-empty one + # is appended as a suffix. + name: str | None | UndefinedType = UNDEFINED + if ( + by_user + and entity.name is None + and (name_unprefixed := entity.original_name_unprefixed) is not None + ): + if not name_unprefixed: + name = device_name + elif device_name: + name = f"{device_name} {name_unprefixed}" + original_name_unprefixed = _async_strip_prefix_from_entity_name( entity.original_name, device_name ) + self._async_update_entity( entity.entity_id, name=name, @@ -1995,7 +1993,6 @@ class EntityRegistry(BaseRegistry): categories=entity["categories"], capabilities=entity["capabilities"], compat_aliases=entity["aliases"], - compat_name=entity["name"], config_entry_id=entity["config_entry_id"], config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), @@ -2016,7 +2013,7 @@ class EntityRegistry(BaseRegistry): has_entity_name=entity["has_entity_name"], labels=set(entity["labels"]), modified_at=datetime.fromisoformat(entity["modified_at"]), - name=entity["name_v2"], + name=entity["name"], object_id_base=entity.get("object_id_base"), options=entity["options"], original_device_class=entity["original_device_class"], @@ -2067,7 +2064,6 @@ class EntityRegistry(BaseRegistry): area_id=entity["area_id"], categories=entity["categories"], compat_aliases=entity["aliases"], - compat_name=entity["name"], config_entry_id=entity["config_entry_id"], config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), @@ -2087,7 +2083,7 @@ class EntityRegistry(BaseRegistry): id=entity["id"], labels=set(entity["labels"]), modified_at=datetime.fromisoformat(entity["modified_at"]), - name=entity["name_v2"], + name=entity["name"], options=entity["options"] if not entity["options_undefined"] else UNDEFINED, diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 95049d01a9a..035d657bbeb 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -801,7 +801,7 @@ async def test_existing_node_not_replaced_when_not_ready( await hass.async_block_till_done() state = hass.states.get(custom_entity) assert state - assert state.name == "Custom Device Name Custom Entity Name" + assert state.name == "Custom Entity Name" assert not hass.states.get(motion_entity) node_state = deepcopy(zp3111_not_ready_state) @@ -835,7 +835,7 @@ async def test_existing_node_not_replaced_when_not_ready( state = hass.states.get(custom_entity) assert state - assert state.name == "Custom Device Name Custom Entity Name" + assert state.name == "Custom Entity Name" event = Event( type="ready", @@ -866,7 +866,7 @@ async def test_existing_node_not_replaced_when_not_ready( state = hass.states.get(custom_entity) assert state assert state.state != STATE_UNAVAILABLE - assert state.name == "Custom Device Name Custom Entity Name" + assert state.name == "Custom Entity Name" @pytest.mark.usefixtures("client") @@ -1857,7 +1857,7 @@ async def test_node_model_change( assert not hass.states.get(motion_entity) state = hass.states.get(custom_entity) assert state - assert state.name == "Custom Device Name Custom Entity Name" + assert state.name == "Custom Entity Name" # Unload the integration assert await hass.config_entries.async_unload(integration.entry_id) @@ -1887,7 +1887,7 @@ async def test_node_model_change( assert not hass.states.get(motion_entity) state = hass.states.get(custom_entity) assert state - assert state.name == "Custom Device Name Custom Entity Name" + assert state.name == "Custom Entity Name" @pytest.mark.usefixtures("zp3111", "integration") diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index b26a7d23d47..94f330f63e2 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -684,7 +684,6 @@ async def test_load_bad_data( "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, - "name_v2": None, "object_id_base": None, "options": None, "original_device_class": None, @@ -719,7 +718,6 @@ async def test_load_bad_data( "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, - "name_v2": None, "object_id_base": None, "options": None, "original_device_class": None, @@ -754,7 +752,6 @@ async def test_load_bad_data( "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, - "name_v2": None, "options": None, "options_undefined": False, "orphaned_timestamp": None, @@ -780,7 +777,6 @@ async def test_load_bad_data( "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, - "name_v2": None, "options": None, "options_undefined": False, "orphaned_timestamp": None, @@ -1135,7 +1131,6 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, - "name_v2": None, "object_id_base": None, "options": {}, "original_device_class": "best_class", @@ -1331,7 +1326,6 @@ async def test_migration_1_11( "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, - "name_v2": None, "object_id_base": None, "options": {}, "original_device_class": "best_class", @@ -1367,7 +1361,6 @@ async def test_migration_1_11( "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, - "name_v2": None, "options": {}, "options_undefined": True, "orphaned_timestamp": None, @@ -1500,7 +1493,6 @@ async def test_migration_1_18( "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, - "name_v2": None, "object_id_base": "Test Entity", "options": {}, "original_device_class": "best_class", @@ -1536,7 +1528,6 @@ async def test_migration_1_18( "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, - "name_v2": None, "options": {}, "options_undefined": False, "orphaned_timestamp": None, @@ -1554,10 +1545,14 @@ async def test_migration_1_18( @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_20( - hass: HomeAssistant, hass_storage: dict[str, Any] +async def test_migration_1_21( + hass: HomeAssistant, + hass_storage: dict[str, Any], ) -> None: - """Test migration from version 1.20.""" + """Test migration from version 1.21. + + Version 1.21 stored entity names in a new format, but was reverted. + """ hass_storage[dr.STORAGE_KEY] = { "version": dr.STORAGE_VERSION_MAJOR, "minor_version": dr.STORAGE_VERSION_MINOR, @@ -1565,24 +1560,25 @@ async def test_migration_1_20( "devices": [ { "area_id": None, - "config_entries": ["mock-config-entry"], - "config_entries_subentries": {"mock-config-entry": [None]}, + "config_entries": ["mock_entry"], + "config_entries_subentries": {"mock_entry": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, + "disabled_by_undefined": False, "entry_type": None, "hw_version": None, - "id": "device-1", - "identifiers": [["test", "device-1"]], + "id": "device_1234", + "identifiers": [["test", "device_1"]], "labels": [], "manufacturer": None, "model": None, "model_id": None, "modified_at": "1970-01-01T00:00:00+00:00", - "name": "My Device", "name_by_user": None, - "primary_config_entry": "mock-config-entry", + "name": "My Device", + "primary_config_entry": "mock_entry", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1591,238 +1587,121 @@ async def test_migration_1_20( "deleted_devices": [], }, } + dr.async_setup(hass) await dr.async_load(hass) - # Entity registry data at version 1.20 + entity_base = { + "aliases": [], + "area_id": None, + "capabilities": {}, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_id": "device_1234", + "disabled_by": None, + "entity_category": None, + "has_entity_name": False, + "hidden_by": None, + "icon": None, + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "object_id_base": "Temperature", + "options": {}, + "original_device_class": "temperature", + "original_icon": None, + "original_name": "Temperature", + "platform": "super_platform", + "previous_unique_id": None, + "suggested_object_id": None, + "supported_features": 0, + "translation_key": None, + "unit_of_measurement": None, + "device_class": None, + } hass_storage[er.STORAGE_KEY] = { "version": 1, - "minor_version": 20, + "minor_version": 21, "data": { "entities": [ { - # Entity with name=None - # name should be preserved - # should add None to aliases - "aliases": [], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.entity_name_no_custom", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-1", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": None, - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-1", - "unit_of_measurement": None, - "device_class": None, - }, - { - # Entity with no device_id - # name should be preserved - # should add None to aliases - "aliases": [], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": None, - "disabled_by": None, - "entity_category": None, - "entity_id": "test.no_device", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-2", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Standalone Sensor", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-2", - "unit_of_measurement": None, - "device_class": None, - }, - { - # Entity with name starting with device name - # name should be stripped to remove device name prefix - # should add None to aliases - "aliases": [], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.name_with_device_prefix", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-3", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "My device temperature", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-3", - "unit_of_measurement": None, - "device_class": None, - }, - { - # Entity with custom name not starting with device name - # not exposed to any voice assistant - # name should be preserved - # should add None to aliases - "aliases": [], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, + **entity_base, "entity_id": "test.custom_name", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-4", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Living Room Light", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-4", - "unit_of_measurement": None, - "device_class": None, + "id": "entity_custom_name", + "unique_id": "custom_name", + "name": "My Custom Name", + "name_v2": "My Custom Name", }, { - # Entity with custom name not starting with device name - # exposed to conversation assistant - # name should be preserved - # should add name to aliases - "aliases": [], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.custom_name_exposed", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-5", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Living Room Light", - "object_id_base": "Test entity", - "options": { - "conversation": {"should_expose": True}, - }, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-5", - "unit_of_measurement": None, - "device_class": None, + **entity_base, + "entity_id": "test.stripped", + "id": "entity_stripped", + "unique_id": "stripped", + "name": "My Device Temperature", + "name_v2": "Temperature", + }, + { + **entity_base, + "entity_id": "test.stripped_and_renamed", + "id": "entity_stripped_and_renamed", + "unique_id": "stripped_and_renamed", + "name": "My Device Temperature", + "name_v2": "Heat", }, ], - "deleted_entities": [ - { - # Deleted entity - # name should be reset to None - # should add None to aliases - "aliases": ["deleted_alias"], - "area_id": None, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_class": None, - "disabled_by": None, - "disabled_by_undefined": False, - "entity_id": "test.deleted_entity", - "hidden_by": None, - "hidden_by_undefined": False, - "icon": None, - "id": "deleted-1", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Deleted Name", - "options": {}, - "options_undefined": False, - "orphaned_timestamp": None, - "platform": "test_platform", - "unique_id": "deleted-unique", - } - ], + "deleted_entities": [], }, } await er.async_load(hass) registry = er.async_get(hass) + entry = registry.async_get_or_create("test", "super_platform", "custom_name") + assert entry.name == "My Custom Name" + + entry = registry.async_get_or_create("test", "super_platform", "stripped") + assert entry.name == "My Device Temperature" + + entry = registry.async_get_or_create( + "test", "super_platform", "stripped_and_renamed" + ) + assert entry.name == "My Device Heat" + # Check migrated data await flush_store(registry._store) migrated_data = hass_storage[er.STORAGE_KEY] + + migrated_entity_base = { + "aliases": [], + "aliases_v2": [None], + "area_id": None, + "capabilities": {}, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_id": "device_1234", + "disabled_by": None, + "entity_category": None, + "has_entity_name": False, + "hidden_by": None, + "icon": None, + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "object_id_base": "Temperature", + "options": {}, + "original_device_class": "temperature", + "original_icon": None, + "original_name": "Temperature", + "platform": "super_platform", + "previous_unique_id": None, + "suggested_object_id": None, + "supported_features": 0, + "translation_key": None, + "unit_of_measurement": None, + "device_class": None, + } assert migrated_data == { "version": er.STORAGE_VERSION_MAJOR, "minor_version": er.STORAGE_VERSION_MINOR, @@ -1830,211 +1709,28 @@ async def test_migration_1_20( "data": { "entities": [ { - "aliases": [], - "aliases_v2": [None], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.entity_name_no_custom", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-1", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": None, - "name_v2": None, - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-1", - "unit_of_measurement": None, - "device_class": None, - }, - { - "aliases": [], - "aliases_v2": [None], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": None, - "disabled_by": None, - "entity_category": None, - "entity_id": "test.no_device", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-2", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Standalone Sensor", - "name_v2": "Standalone Sensor", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-2", - "unit_of_measurement": None, - "device_class": None, - }, - { - "aliases": [], - "aliases_v2": [None], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.name_with_device_prefix", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-3", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "My device temperature", - "name_v2": "Temperature", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-3", - "unit_of_measurement": None, - "device_class": None, - }, - { - "aliases": [], - "aliases_v2": [None], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, + **migrated_entity_base, "entity_id": "test.custom_name", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-4", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Living Room Light", - "name_v2": "Living Room Light", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-4", - "unit_of_measurement": None, - "device_class": None, + "id": "entity_custom_name", + "unique_id": "custom_name", + "name": "My Custom Name", }, { - "aliases": [], - "aliases_v2": ["Living Room Light"], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.custom_name_exposed", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-5", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Living Room Light", - "name_v2": "Living Room Light", - "object_id_base": "Test entity", - "options": { - "conversation": {"should_expose": True}, - }, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-5", - "unit_of_measurement": None, - "device_class": None, + **migrated_entity_base, + "entity_id": "test.stripped", + "id": "entity_stripped", + "unique_id": "stripped", + "name": "My Device Temperature", }, - ], - "deleted_entities": [ { - "aliases": ["deleted_alias"], - "aliases_v2": [None, "deleted_alias"], - "area_id": None, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_class": None, - "disabled_by": None, - "disabled_by_undefined": False, - "entity_id": "test.deleted_entity", - "hidden_by": None, - "hidden_by_undefined": False, - "icon": None, - "id": "deleted-1", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Deleted Name", - "name_v2": None, - "options": {}, - "options_undefined": False, - "orphaned_timestamp": None, - "platform": "test_platform", - "unique_id": "deleted-unique", + **migrated_entity_base, + "entity_id": "test.stripped_and_renamed", + "id": "entity_stripped_and_renamed", + "unique_id": "stripped_and_renamed", + "name": "My Device Heat", }, ], + "deleted_entities": [], }, } @@ -3353,7 +3049,7 @@ async def test_has_entity_name_false_device_name_changes( assert updated.original_name_unprefixed == "Light Temperature" updated2 = entity_registry.async_get(entry2.entity_id) - assert updated2.name == "Brightness" + assert updated2.name == "Hue Brightness" assert updated2.original_name_unprefixed is None updated3 = entity_registry.async_get(entry3.entity_id) diff --git a/tests/syrupy.py b/tests/syrupy.py index db173602860..ec795151955 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -203,7 +203,6 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): ) serialized.pop("categories") serialized.pop("compat_aliases") - serialized.pop("compat_name") serialized.pop("original_name_unprefixed") serialized.pop("_cache") serialized["aliases"] = er._serialize_aliases(serialized["aliases"]) From 336aa0f5df7a8e06d25d6b0764a571e0a575ef3e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 27 Mar 2026 22:45:11 +0100 Subject: [PATCH 061/138] Update frontend to 20260325.2 (#166717) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d43174468c8..4c8256a82e6 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.1"] + "requirements": ["home-assistant-frontend==20260325.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 37e26f6f716..13e943984c7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.11.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.1 +home-assistant-frontend==20260325.2 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 07f87fc3979..17ba33ac9ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.1 +home-assistant-frontend==20260325.2 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8163e2f8c24..88d96d7c152 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.1 +home-assistant-frontend==20260325.2 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From c83032073019180ffb86a406e3caccb462334aa0 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 27 Mar 2026 22:46:53 +0100 Subject: [PATCH 062/138] Bump version to 2026.4.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 308887b98fd..749649d7e1e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index 7e2409cc226..6b5d1f17859 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0b3" +version = "2026.4.0b4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From d769b16ada62fdce8fb9dd7b7a666233f63c87a8 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 28 Mar 2026 17:27:09 +0100 Subject: [PATCH 063/138] Add new OAuth exceptions to Neato (#166584) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/neato/__init__.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index 2c273f9d158..318396d6a8a 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -2,14 +2,19 @@ import logging -import aiohttp +from aiohttp import ClientError from pybotvac import Account from pybotvac.exceptions import NeatoException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -58,10 +63,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = OAuth2Session(hass, entry, implementation) try: await session.async_ensure_token_valid() - except aiohttp.ClientResponseError as ex: - _LOGGER.debug("API error: %s (%s)", ex.code, ex.message) - if ex.code in (401, 403): - raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + except OAuth2TokenRequestReauthError as ex: + raise ConfigEntryAuthFailed from ex + except (OAuth2TokenRequestError, ClientError) as ex: raise ConfigEntryNotReady from ex neato_session = api.ConfigEntryAuth(hass, entry, implementation) From 133453174083d20ec0a3e9b2985dd02aa35dae30 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:52:42 -0700 Subject: [PATCH 064/138] Handle Oauth2 ImplementationUnavailableError in husqvarna_automower (#166633) Co-authored-by: Claude Sonnet 4.6 --- .../husqvarna_automower/__init__.py | 17 ++++++++++++---- .../husqvarna_automower/strings.json | 3 +++ .../husqvarna_automower/test_init.py | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 9ff6b1e06f8..c8260d4d825 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -11,6 +11,9 @@ from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, ) +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -42,11 +45,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> bool: """Set up this integration using UI.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) api_api = api.AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 912c6c3b51a..b40d3666247 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -491,6 +491,9 @@ "command_send_failed": { "message": "Failed to send command: {exception}" }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, "work_area_not_existing": { "message": "The selected work area does not exist." }, diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index e5d26400c37..14fa01fa938 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -24,6 +24,9 @@ from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERV from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.util import dt as dt_util from . import setup_integration @@ -722,3 +725,20 @@ async def test_websocket_watchdog( async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_automower_client.get_status.call_count == 2 + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From a48db9d817617ad7ea526dd18252739ca4183c22 Mon Sep 17 00:00:00 2001 From: crash0verride11 Date: Sat, 28 Mar 2026 12:23:57 -0400 Subject: [PATCH 065/138] Correct Musiccast sound mode name (#166644) Co-authored-by: crash0verride11 <3526616+crash0verride11@users.noreply.github.com> Co-authored-by: jtjart <80978647+jtjart@users.noreply.github.com> Co-authored-by: Joostlek --- homeassistant/components/yamaha_musiccast/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index dd6c86af6aa..9768fa613f0 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -81,8 +81,8 @@ "usa_a": "Hall in USA A", "usa_b": "Hall in USA B", "vienna": "Hall in Vienna", - "village_gate": "Village gate", - "village_vanguard": "Village vanguard", + "village_gate": "Village Gate", + "village_vanguard": "Village Vanguard", "warehouse_loft": "Warehouse loft" } } From 08ef4e0de0da6e6f4123944479df9a040ccb5922 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:51:09 -0700 Subject: [PATCH 066/138] Handle Oauth2 ImplementationUnavailableError in gentex_homelink (#166646) Co-authored-by: Claude Sonnet 4.6 --- .../components/gentex_homelink/__init__.py | 16 +++++++++----- .../components/gentex_homelink/strings.json | 5 +++++ tests/components/gentex_homelink/test_init.py | 21 +++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gentex_homelink/__init__.py b/homeassistant/components/gentex_homelink/__init__.py index cdd37a7920f..68cf0dfac52 100644 --- a/homeassistant/components/gentex_homelink/__init__.py +++ b/homeassistant/components/gentex_homelink/__init__.py @@ -7,7 +7,7 @@ from homelink.mqtt_provider import MQTTProvider from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import oauth2 @@ -29,11 +29,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> hass, DOMAIN, auth_implementation ) - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) authenticated_session = oauth2.AsyncConfigEntryAuth( diff --git a/homeassistant/components/gentex_homelink/strings.json b/homeassistant/components/gentex_homelink/strings.json index 0a5fec312da..111fca492da 100644 --- a/homeassistant/components/gentex_homelink/strings.json +++ b/homeassistant/components/gentex_homelink/strings.json @@ -49,5 +49,10 @@ } } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/gentex_homelink/test_init.py b/tests/components/gentex_homelink/test_init.py index 607430cdab0..d4003c3d0a1 100644 --- a/tests/components/gentex_homelink/test_init.py +++ b/tests/components/gentex_homelink/test_init.py @@ -8,6 +8,9 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.gentex_homelink.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) import homeassistant.helpers.device_registry as dr from . import setup_integration, update_callback @@ -72,3 +75,21 @@ async def test_load_unload_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("aioclient_mock_fixture") +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 1876ed7d1607691a83da357c72dd3408c3e85f9e Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:53:20 -0700 Subject: [PATCH 067/138] Handle Oauth2 ImplementationUnavailableError in geocaching (#166648) Co-authored-by: Claude Sonnet 4.6 --- .../components/geocaching/__init__.py | 11 +++++++- .../components/geocaching/strings.json | 5 ++++ tests/components/geocaching/test_init.py | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/components/geocaching/test_init.py diff --git a/homeassistant/components/geocaching/__init__.py b/homeassistant/components/geocaching/__init__.py index 144249ac42f..be9e3c29b93 100644 --- a/homeassistant/components/geocaching/__init__.py +++ b/homeassistant/components/geocaching/__init__.py @@ -2,11 +2,14 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) +from .const import DOMAIN from .coordinator import GeocachingConfigEntry, GeocachingDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -14,7 +17,13 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: GeocachingConfigEntry) -> bool: """Set up Geocaching from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err oauth_session = OAuth2Session(hass, entry, implementation) coordinator = GeocachingDataUpdateCoordinator( diff --git a/homeassistant/components/geocaching/strings.json b/homeassistant/components/geocaching/strings.json index 4c31566e7b4..896a239fb2c 100644 --- a/homeassistant/components/geocaching/strings.json +++ b/homeassistant/components/geocaching/strings.json @@ -65,5 +65,10 @@ "unit_of_measurement": "souvenirs" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/geocaching/test_init.py b/tests/components/geocaching/test_init.py new file mode 100644 index 00000000000..97d6753e11a --- /dev/null +++ b/tests/components/geocaching/test_init.py @@ -0,0 +1,28 @@ +"""Tests for the Geocaching integration.""" + +from unittest.mock import patch + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) + +from tests.common import MockConfigEntry + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.geocaching.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 76ae6958ede8f157d1a6d79fbe69926854945ac4 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:55:55 -0700 Subject: [PATCH 068/138] Handle Oauth2 ImplementationUnavailableError in google_assistant_sdk (#166649) Co-authored-by: Claude Sonnet 4.6 --- .../google_assistant_sdk/__init__.py | 9 ++++++++- .../google_assistant_sdk/strings.json | 3 +++ .../google_assistant_sdk/test_init.py | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 8d98da2fe4e..d972a4d8d3f 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery, intent from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) @@ -47,7 +48,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry ) -> bool: """Set up Google Assistant SDK from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = OAuth2Session(hass, entry, implementation) try: await session.async_ensure_token_valid() diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 8a509430c53..b1997ff06b3 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -48,6 +48,9 @@ "grpc_error": { "message": "Failed to communicate with Google Assistant" }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, "reauth_required": { "message": "Credentials are invalid, re-authentication required" } diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index e45037a19bd..d214723fcfc 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -16,6 +16,9 @@ from homeassistant.components.google_assistant_sdk.const import SUPPORTED_LANGUA from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.setup import async_setup_component from .conftest import ComponentSetup, ExpectedCredentials @@ -492,3 +495,20 @@ async def test_conversation_agent_language_changed( mock_text_assistant.assert_has_calls([call(ExpectedCredentials(), "es-ES")]) mock_text_assistant.assert_has_calls([call().assist(text1)]) mock_text_assistant.assert_has_calls([call().assist(text2)]) + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.google_assistant_sdk.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From fac270206333cfd3d56be70e05937b8a625ec0f0 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:55:24 -0700 Subject: [PATCH 069/138] Handle Oauth2 ImplementationUnavailableError in google_mail (#166650) Co-authored-by: Claude Sonnet 4.6 --- .../components/google_mail/__init__.py | 10 ++++++++- .../components/google_mail/strings.json | 3 +++ tests/components/google_mail/test_init.py | 21 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 534ce783cbc..844b5efb65e 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -5,8 +5,10 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) @@ -34,7 +36,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -> bool: """Set up Google Mail from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = OAuth2Session(hass, entry, implementation) auth = AsyncConfigEntryAuth(hass, session) await auth.check_and_refresh_token() diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index e1c74a6553d..d1e4472c208 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -51,6 +51,9 @@ "exceptions": { "missing_from_for_alias": { "message": "Missing 'from' email when setting an alias to show. You have to provide a 'from' email" + }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } }, "services": { diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py index 91e7d4abe0a..791ef6f8e88 100644 --- a/tests/components/google_mail/test_init.py +++ b/tests/components/google_mail/test_init.py @@ -11,9 +11,13 @@ from homeassistant.components.google_mail import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from .conftest import GOOGLE_TOKEN_URI, ComponentSetup +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -134,3 +138,20 @@ async def test_device_info( assert device.identifiers == {(DOMAIN, entry.entry_id)} assert device.manufacturer == "Google, Inc." assert device.name == "example@gmail.com" + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.google_mail.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From c1606f515baf8adb82f031b11ce69db34759215b Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:53:47 -0700 Subject: [PATCH 070/138] Handle Oauth2 ImplementationUnavailableError in google_sheets (#166651) Co-authored-by: Claude Sonnet 4.6 --- .../components/google_sheets/__init__.py | 9 ++++++++- .../components/google_sheets/strings.json | 5 +++++ tests/components/google_sheets/test_init.py | 20 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 99981348151..de88c6028b9 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -15,6 +15,7 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) @@ -40,7 +41,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: GoogleSheetsConfigEntry ) -> bool: """Set up Google Sheets from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = OAuth2Session(hass, entry, implementation) try: await session.async_ensure_token_valid() diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index c748aace698..7dfe6bc3612 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -42,6 +42,11 @@ } } }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } + }, "services": { "append_sheet": { "description": "Appends data to a worksheet in Google Sheets.", diff --git a/tests/components/google_sheets/test_init.py b/tests/components/google_sheets/test_init.py index 7bb7369c7b5..d6782c2e446 100644 --- a/tests/components/google_sheets/test_init.py +++ b/tests/components/google_sheets/test_init.py @@ -35,6 +35,9 @@ from homeassistant.exceptions import ( OAuth2TokenRequestTransientError, ServiceValidationError, ) +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -558,3 +561,20 @@ async def test_get_sheet_invalid_worksheet( blocking=True, return_response=True, ) + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.google_sheets.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 2d8c903533b58625c26146d70bb474238c0f714d Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:54:00 -0700 Subject: [PATCH 071/138] Handle Oauth2 ImplementationUnavailableError in iotty (#166652) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/iotty/__init__.py | 11 ++++++++++- homeassistant/components/iotty/strings.json | 5 +++++ tests/components/iotty/test_init.py | 22 ++++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/iotty/__init__.py b/homeassistant/components/iotty/__init__.py index c9eb2639348..02e69126492 100644 --- a/homeassistant/components/iotty/__init__.py +++ b/homeassistant/components/iotty/__init__.py @@ -6,11 +6,14 @@ import logging from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) +from .const import DOMAIN from .coordinator import ( IottyConfigEntry, IottyConfigEntryData, @@ -26,7 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: IottyConfigEntry) -> boo """Set up iotty from a config entry.""" _LOGGER.debug("async_setup_entry entry_id=%s", entry.entry_id) - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = OAuth2Session(hass, entry, implementation) data_update_coordinator = IottyDataUpdateCoordinator(hass, entry, session) diff --git a/homeassistant/components/iotty/strings.json b/homeassistant/components/iotty/strings.json index 33176be1235..2e37d0c99f0 100644 --- a/homeassistant/components/iotty/strings.json +++ b/homeassistant/components/iotty/strings.json @@ -25,5 +25,10 @@ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/iotty/test_init.py b/tests/components/iotty/test_init.py index ee8168fdf2f..5616438e2d4 100644 --- a/tests/components/iotty/test_init.py +++ b/tests/components/iotty/test_init.py @@ -1,11 +1,14 @@ """Tests for the iotty integration.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from homeassistant.components.iotty.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from tests.common import MockConfigEntry @@ -41,6 +44,23 @@ async def test_load_unload_coordinator_called( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.iotty.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_load_unload_iottyproxy_called( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From ff8fc56696b5dc90e88b97025b230de78fe8b28a Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:56:39 -0700 Subject: [PATCH 072/138] Handle Oauth2 ImplementationUnavailableError in monzo (#166653) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/monzo/__init__.py | 11 ++++++++++- homeassistant/components/monzo/strings.json | 5 +++++ tests/components/monzo/test_init.py | 22 ++++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/monzo/__init__.py b/homeassistant/components/monzo/__init__.py index e0aa3f3a847..b0a516ae8ad 100644 --- a/homeassistant/components/monzo/__init__.py +++ b/homeassistant/components/monzo/__init__.py @@ -6,13 +6,16 @@ import logging from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) from .api import AuthenticatedMonzoAPI +from .const import DOMAIN from .coordinator import MonzoConfigEntry, MonzoCoordinator _LOGGER = logging.getLogger(__name__) @@ -39,7 +42,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: MonzoConfigEntry) -> b async def async_setup_entry(hass: HomeAssistant, entry: MonzoConfigEntry) -> bool: """Set up Monzo from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = OAuth2Session(hass, entry, implementation) diff --git a/homeassistant/components/monzo/strings.json b/homeassistant/components/monzo/strings.json index edb1cd513f6..8f727194ab6 100644 --- a/homeassistant/components/monzo/strings.json +++ b/homeassistant/components/monzo/strings.json @@ -50,5 +50,10 @@ "name": "Total balance" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/monzo/test_init.py b/tests/components/monzo/test_init.py index f255160f1ed..11fe55f3cf1 100644 --- a/tests/components/monzo/test_init.py +++ b/tests/components/monzo/test_init.py @@ -7,8 +7,11 @@ from freezegun.api import FrozenDateTimeFactory from monzopy import AuthorisationExpiredError from homeassistant.components.monzo.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from . import setup_integration @@ -61,3 +64,20 @@ async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: assert entry.version == 1 assert entry.minor_version == 2 assert entry.unique_id == "600" + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + polling_config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + polling_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.monzo.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert polling_config_entry.state is ConfigEntryState.SETUP_RETRY From dd746656226595f40105c07b68d64857eb34c6b5 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 09:18:05 -0700 Subject: [PATCH 073/138] Handle Oauth2 ImplementationUnavailableError in microbees (#166654) Co-authored-by: Claude Sonnet 4.6 --- .../components/microbees/__init__.py | 16 +++++++++----- .../components/microbees/strings.json | 5 +++++ tests/components/microbees/test_init.py | 21 +++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/microbees/__init__.py b/homeassistant/components/microbees/__init__.py index af5d4aa32c7..f0a39dc0352 100644 --- a/homeassistant/components/microbees/__init__.py +++ b/homeassistant/components/microbees/__init__.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow -from .const import PLATFORMS +from .const import DOMAIN, PLATFORMS from .coordinator import MicroBeesUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -50,11 +50,17 @@ async def async_migrate_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry) async def async_setup_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry) -> bool: """Set up microBees from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) try: diff --git a/homeassistant/components/microbees/strings.json b/homeassistant/components/microbees/strings.json index 87c91b8c9ad..1ad8adbd63d 100644 --- a/homeassistant/components/microbees/strings.json +++ b/homeassistant/components/microbees/strings.json @@ -35,5 +35,10 @@ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/microbees/test_init.py b/tests/components/microbees/test_init.py index f70c8387572..83228d8babf 100644 --- a/tests/components/microbees/test_init.py +++ b/tests/components/microbees/test_init.py @@ -3,7 +3,11 @@ from unittest.mock import patch from homeassistant.components.microbees.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from tests.common import MockConfigEntry @@ -33,3 +37,20 @@ async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: assert entry.version == 1 assert entry.minor_version == 2 assert entry.unique_id == "54321" + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 15e13de2a6161cd5438d049e5f7ff1bc24019040 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:51:48 -0700 Subject: [PATCH 074/138] Handle Oauth2 ImplementationUnavailableError in lyric (#166655) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/lyric/__init__.py | 15 +++++--- homeassistant/components/lyric/strings.json | 5 +++ tests/components/lyric/test_init.py | 40 +++++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 tests/components/lyric/test_init.py diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index c221b03a891..95fb559491d 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -6,6 +6,7 @@ from aiolyric import Lyric from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, @@ -27,11 +28,17 @@ PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool: """Set up Honeywell Lyric from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err if not isinstance(implementation, LyricLocalOAuth2Implementation): raise TypeError("Unexpected auth implementation; can't find oauth client id") diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index c3bace886d4..51f1cff5269 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -64,6 +64,11 @@ } } }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } + }, "services": { "set_hold_time": { "description": "Sets the time period to keep the temperature and override the schedule.", diff --git a/tests/components/lyric/test_init.py b/tests/components/lyric/test_init.py new file mode 100644 index 00000000000..43316079fe1 --- /dev/null +++ b/tests/components/lyric/test_init.py @@ -0,0 +1,40 @@ +"""Tests for the Honeywell Lyric integration.""" + +from unittest.mock import patch + +from homeassistant.components.lyric.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) + +from tests.common import MockConfigEntry + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": 9999999999, + "token_type": "Bearer", + }, + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY From ada549489c7f981f1fcfb5a51390c5b076f0a48c Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:50:01 -0700 Subject: [PATCH 075/138] Handle Oauth2 ImplementationUnavailableError in google_tasks (#166657) Co-authored-by: Claude Sonnet 4.6 --- .../components/google_tasks/__init__.py | 14 ++++++++---- .../components/google_tasks/strings.json | 5 +++++ tests/components/google_tasks/test_init.py | 22 ++++++++++++++++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py index 2d570854ad4..494295f69f2 100644 --- a/homeassistant/components/google_tasks/__init__.py +++ b/homeassistant/components/google_tasks/__init__.py @@ -25,11 +25,17 @@ PLATFORMS: list[Platform] = [Platform.TODO] async def async_setup_entry(hass: HomeAssistant, entry: GoogleTasksConfigEntry) -> bool: """Set up Google Tasks from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) auth = api.AsyncConfigEntryAuth(hass, session) try: diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index d8ff18e0a7f..ea329c61bd1 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -42,5 +42,10 @@ "title": "[%key:common::config_flow::title::reauth%]" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/google_tasks/test_init.py b/tests/components/google_tasks/test_init.py index e93e0d9c643..c1ed1cf609f 100644 --- a/tests/components/google_tasks/test_init.py +++ b/tests/components/google_tasks/test_init.py @@ -5,7 +5,7 @@ import http from http import HTTPStatus import json import time -from unittest.mock import Mock +from unittest.mock import Mock, patch from aiohttp import ClientError from httplib2 import Response @@ -15,6 +15,9 @@ from homeassistant.components.google_tasks import DOMAIN from homeassistant.components.google_tasks.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from .conftest import LIST_TASK_LIST_RESPONSE, LIST_TASKS_RESPONSE_WATER @@ -152,3 +155,20 @@ async def test_setup_error( assert not await integration_setup() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.google_tasks.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 87e648b8b827789fd2ef2d59d3c4b7c7e340d7a4 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sat, 28 Mar 2026 05:15:29 -0500 Subject: [PATCH 076/138] Bump aiorussound to 4.9.1 (#166718) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 43683722a0c..588f1396036 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.9.0"], + "requirements": ["aiorussound==4.9.1"], "zeroconf": ["_rio._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 17ba33ac9ca..3a9ad2df398 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -389,7 +389,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.9.0 +aiorussound==4.9.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 88d96d7c152..66fcd2e58bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -374,7 +374,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.9.0 +aiorussound==4.9.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From fca87a2b8a11d68f1467cfbbf2daf57c1c60e3c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Sat, 28 Mar 2026 15:27:06 +0100 Subject: [PATCH 077/138] Add missing code for miele washing machine (#166731) --- homeassistant/components/miele/const.py | 1 + tests/components/miele/snapshots/test_sensor.ambr | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 2f8215767fc..52d728ef9db 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -440,6 +440,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True): no_program = 0, -1 cottons = 1, 10001 + normal = 2 minimum_iron = 3 delicates = 4, 10022 woollens = 8, 10040 diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index b1bc36daedd..1cdea6c5b80 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -7613,6 +7613,7 @@ 'game_pieces', 'minimum_iron', 'no_program', + 'normal', 'outdoor_garments', 'outerwear', 'pillows', @@ -7697,6 +7698,7 @@ 'game_pieces', 'minimum_iron', 'no_program', + 'normal', 'outdoor_garments', 'outerwear', 'pillows', @@ -11419,6 +11421,7 @@ 'game_pieces', 'minimum_iron', 'no_program', + 'normal', 'outdoor_garments', 'outerwear', 'pillows', @@ -11503,6 +11506,7 @@ 'game_pieces', 'minimum_iron', 'no_program', + 'normal', 'outdoor_garments', 'outerwear', 'pillows', From d6dbcc8d826ec371d48207d14351e24376ae0e4c Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Sat, 28 Mar 2026 15:26:35 +0100 Subject: [PATCH 078/138] Bump pyblu to 2.0.6 (#166738) --- homeassistant/components/bluesound/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 53109568fb5..5e4e301d46b 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/bluesound", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pyblu==2.0.5"], + "requirements": ["pyblu==2.0.6"], "zeroconf": [ { "type": "_musc._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 3a9ad2df398..ab4f8413cf8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1989,7 +1989,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.5 +pyblu==2.0.6 # homeassistant.components.neato pybotvac==0.0.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66fcd2e58bf..773322b1d0f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1723,7 +1723,7 @@ pybalboa==1.1.3 pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.5 +pyblu==2.0.6 # homeassistant.components.neato pybotvac==0.0.28 From 7e4757c213a283c1358a0b461b75b97a84bea39d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:50:26 +0100 Subject: [PATCH 079/138] Bump aioimmich to 0.12.1 (#166746) --- homeassistant/components/immich/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 25ecc5cec1c..2a0680e314a 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "platinum", - "requirements": ["aioimmich==0.12.0"] + "requirements": ["aioimmich==0.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab4f8413cf8..cc6ab9ee660 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -294,7 +294,7 @@ aiohue==4.8.0 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.12.0 +aioimmich==0.12.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 773322b1d0f..8fdcfb557fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -282,7 +282,7 @@ aiohue==4.8.0 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.12.0 +aioimmich==0.12.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 From afce52a0f47119ab453e7b86b1d1fedb892779cf Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 28 Mar 2026 12:12:24 -0400 Subject: [PATCH 080/138] Bump pydrawise to 2026.3.0 (#166750) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 2ad8d8f36bd..069ca3ef500 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.9.0"] + "requirements": ["pydrawise==2026.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cc6ab9ee660..f0013f8571d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2055,7 +2055,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.9.0 +pydrawise==2026.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8fdcfb557fc..f78cfe932d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1768,7 +1768,7 @@ pydexcom==0.5.1 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.9.0 +pydrawise==2026.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 From 8b0ec21a1582b9073ec41270e10503bfe25b1cad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 10:22:30 -1000 Subject: [PATCH 081/138] Bump aiohttp to 3.13.4 (#166756) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 13e943984c7..430e8d81f1c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==4.0.0 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.3 +aiohttp==3.13.4 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 6b5d1f17859..ce332f21062 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # module level in `bootstrap.py` and its requirements thus need to be in # requirements.txt to ensure they are always installed "aiogithubapi==26.0.0", - "aiohttp==3.13.3", + "aiohttp==3.13.4", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index a8c09abd60b..50c1319f8be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ aiodns==4.0.0 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.3 +aiohttp==3.13.4 aiohttp_cors==0.8.1 aiozoneinfo==0.2.3 annotatedyaml==1.0.2 From 112ad886c6642895775b808abc2178dcf82d0c4d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 28 Mar 2026 21:59:36 +0100 Subject: [PATCH 082/138] Revert mqtt vacuum segments support (#166761) --- .../components/mqtt/abbreviations.py | 3 - homeassistant/components/mqtt/entity.py | 5 - homeassistant/components/mqtt/vacuum.py | 92 +---- tests/components/mqtt/test_vacuum.py | 371 +----------------- 4 files changed, 6 insertions(+), 465 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 9892384e804..4cc391e0ca7 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -18,8 +18,6 @@ ABBREVIATIONS = { "bri_stat_t": "brightness_state_topic", "bri_tpl": "brightness_template", "bri_val_tpl": "brightness_value_template", - "cln_segmnts_cmd_t": "clean_segments_command_topic", - "cln_segmnts_cmd_tpl": "clean_segments_command_template", "clr_temp_cmd_tpl": "color_temp_command_template", "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", @@ -187,7 +185,6 @@ ABBREVIATIONS = { "rgbww_cmd_t": "rgbww_command_topic", "rgbww_stat_t": "rgbww_state_topic", "rgbww_val_tpl": "rgbww_value_template", - "segmnts": "segments", "send_cmd_t": "send_command_topic", "send_if_off": "send_if_off", "set_fan_spd_t": "set_fan_speed_topic", diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index a101612f793..12b6aac94bf 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -1484,7 +1484,6 @@ class MqttEntity( self._config = config self._setup_from_config(self._config) self._setup_common_attributes_from_config(self._config) - self._process_entity_update() # Prepare MQTT subscriptions self.attributes_prepare_discovery_update(config) @@ -1587,10 +1586,6 @@ class MqttEntity( def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - @callback - def _process_entity_update(self) -> None: - """Process an entity discovery update.""" - @abstractmethod @callback def _prepare_subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 3ec8566029d..6896d51ef93 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -10,13 +10,12 @@ import voluptuous as vol from homeassistant.components import vacuum from homeassistant.components.vacuum import ( ENTITY_ID_FORMAT, - Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -28,7 +27,7 @@ from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC from .entity import MqttEntity, async_setup_entity_entry_helper -from .models import MqttCommandTemplate, ReceiveMessage +from .models import ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic @@ -53,9 +52,6 @@ POSSIBLE_STATES: dict[str, VacuumActivity] = { STATE_CLEANING: VacuumActivity.CLEANING, } -CONF_SEGMENTS = "segments" -CONF_CLEAN_SEGMENTS_COMMAND_TOPIC = "clean_segments_command_topic" -CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE = "clean_segments_command_template" CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES CONF_PAYLOAD_TURN_ON = "payload_turn_on" CONF_PAYLOAD_TURN_OFF = "payload_turn_off" @@ -141,39 +137,8 @@ MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset( MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/" -def validate_clean_area_config(config: ConfigType) -> ConfigType: - """Check for a valid configuration and check segments.""" - if (config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC not in config) or ( - not config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config - ): - raise vol.Invalid( - f"Options `{CONF_SEGMENTS}` and " - f"`{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}` must be defined together" - ) - segments: list[str] - if segments := config[CONF_SEGMENTS]: - if not config.get(CONF_UNIQUE_ID): - raise vol.Invalid( - f"Option `{CONF_SEGMENTS}` requires `{CONF_UNIQUE_ID}` to be configured" - ) - unique_segments: set[str] = set() - for segment in segments: - segment_id, _, _ = segment.partition(".") - if not segment_id or segment_id in unique_segments: - raise vol.Invalid( - f"The `{CONF_SEGMENTS}` option contains an invalid or non-" - f"unique segment ID '{segment_id}'. Got {segments}" - ) - unique_segments.add(segment_id) - - return config - - -_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { - vol.Optional(CONF_SEGMENTS, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( cv.ensure_list, [cv.string] ), @@ -199,10 +164,7 @@ _BASE_SCHEMA = MQTT_BASE_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -PLATFORM_SCHEMA_MODERN = vol.All(_BASE_SCHEMA, validate_clean_area_config) -DISCOVERY_SCHEMA = vol.All( - _BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_clean_area_config -) +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA) async def async_setup_entry( @@ -229,11 +191,9 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED - _segments: list[Segment] _command_topic: str | None _set_fan_speed_topic: str | None _send_command_topic: str | None - _clean_segments_command_topic: str _payloads: dict[str, str | None] def __init__( @@ -269,23 +229,6 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): self._attr_supported_features = _strings_to_services( supported_feature_strings, STRING_TO_SERVICE ) - if config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config: - self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA - segments: list[str] = config[CONF_SEGMENTS] - self._segments = [ - Segment(id=segment_id, name=name or segment_id) - for segment_id, _, name in [ - segment.partition(".") for segment in segments - ] - ] - self._clean_segments_command_topic = config[ - CONF_CLEAN_SEGMENTS_COMMAND_TOPIC - ] - self._clean_segments_command_template = MqttCommandTemplate( - config.get(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE), - entity=self, - ).async_render - self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] self._command_topic = config.get(CONF_COMMAND_TOPIC) self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) @@ -303,20 +246,6 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): ) } - @callback - def _process_entity_update(self) -> None: - """Check vacuum segments with registry entry.""" - if ( - self._attr_supported_features & VacuumEntityFeature.CLEAN_AREA - and (last_seen := self.last_seen_segments) is not None - and {s.id: s for s in last_seen} != {s.id: s for s in self._segments} - ): - self.async_create_segments_issue() - - async def mqtt_async_added_to_hass(self) -> None: - """Check vacuum segments with registry entry.""" - self._process_entity_update() - def _update_state_attributes(self, payload: dict[str, Any]) -> None: """Update the entity state attributes.""" self._state_attrs.update(payload) @@ -348,19 +277,6 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) - async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: - """Perform an area clean.""" - await self.async_publish_with_config( - self._clean_segments_command_topic, - self._clean_segments_command_template( - json_dumps(segment_ids), {"value": segment_ids} - ), - ) - - async def async_get_segments(self) -> list[Segment]: - """Return the available segments.""" - return self._segments - async def _async_publish_command(self, feature: VacuumEntityFeature) -> None: """Publish a command.""" if self._command_topic is None: diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index 1ddb30c404c..ea5d9f8f8e7 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -3,7 +3,7 @@ from copy import deepcopy import json from typing import Any -from unittest.mock import call, patch +from unittest.mock import patch import pytest @@ -30,7 +30,6 @@ from homeassistant.components.vacuum import ( from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er, issue_registry as ir from .common import ( help_custom_config, @@ -64,11 +63,7 @@ from .common import ( from tests.common import async_fire_mqtt_message from tests.components.vacuum import common -from tests.typing import ( - MqttMockHAClientGenerator, - MqttMockPahoClient, - WebSocketGenerator, -) +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient COMMAND_TOPIC = "vacuum/command" SEND_COMMAND_TOPIC = "vacuum/send_command" @@ -87,27 +82,6 @@ DEFAULT_CONFIG = { } } -CONFIG_CLEAN_SEGMENTS_1 = { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["Livingroom", "Kitchen"], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } -} -CONFIG_CLEAN_SEGMENTS_2 = { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["1.Livingroom", "2.Kitchen"], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } -} - DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}} CONFIG_ALL_SERVICES = help_custom_config( @@ -320,347 +294,6 @@ async def test_command_without_command_topic( mqtt_mock.async_publish.reset_mock() -@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_1]) -async def test_clean_segments_initial_setup_without_repair_issue( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, -) -> None: - """Test cleanable segments initial setup does not fire repair flow.""" - await mqtt_mock_entry() - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 0 - - -@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_1]) -async def test_clean_segments_command_without_id( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - entity_registry: er.EntityRegistry, - mqtt_mock_entry: MqttMockHAClientGenerator, -) -> None: - """Test cleanable segments without ID.""" - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - entity_registry.async_get_or_create( - vacuum.DOMAIN, - mqtt.DOMAIN, - "veryunique", - config_entry=config_entry, - suggested_object_id="test", - ) - entity_registry.async_update_entity_options( - "vacuum.test", - vacuum.DOMAIN, - { - "area_mapping": {"Nabu Casa": ["Kitchen", "Livingroom"]}, - "last_seen_segments": [ - {"id": "Livingroom", "name": "Livingroom"}, - {"id": "Kitchen", "name": "Kitchen"}, - ], - }, - ) - mqtt_mock = await mqtt_mock_entry() - await hass.async_block_till_done() - issue_registry = ir.async_get(hass) - # We do not expect a repair flow - assert len(issue_registry.issues) == 0 - - state = hass.states.get("vacuum.test") - assert state.state == STATE_UNKNOWN - await common.async_clean_area(hass, ["Nabu Casa"], entity_id="vacuum.test") - assert ( - call("vacuum/clean_segment", '["Kitchen","Livingroom"]', 0, False) - in mqtt_mock.async_publish.mock_calls - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - {"type": "vacuum/get_segments", "entity_id": "vacuum.test"} - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"]["segments"] == [ - {"id": "Livingroom", "name": "Livingroom", "group": None}, - {"id": "Kitchen", "name": "Kitchen", "group": None}, - ] - - -@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_2]) -async def test_clean_segments_command_with_id( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - entity_registry: er.EntityRegistry, - mqtt_mock_entry: MqttMockHAClientGenerator, -) -> None: - """Test cleanable segments with ID.""" - mqtt_mock = await mqtt_mock_entry() - # Set the area mapping - entity_registry.async_update_entity_options( - "vacuum.test", - vacuum.DOMAIN, - { - "area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]}, - "last_seen_segments": [ - {"id": "1", "name": "Livingroom"}, - {"id": "2", "name": "Kitchen"}, - ], - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.test") - assert state.state == STATE_UNKNOWN - await common.async_clean_area(hass, ["Kitchen"], entity_id="vacuum.test") - assert ( - call("vacuum/clean_segment", '["2"]', 0, False) - in mqtt_mock.async_publish.mock_calls - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - {"type": "vacuum/get_segments", "entity_id": "vacuum.test"} - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"]["segments"] == [ - {"id": "1", "name": "Livingroom", "group": None}, - {"id": "2", "name": "Kitchen", "group": None}, - ] - - -async def test_clean_segments_command_update( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - entity_registry: er.EntityRegistry, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test cleanable segments update via discovery.""" - # Prepare original entity config entry - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - entity_registry.async_get_or_create( - vacuum.DOMAIN, - mqtt.DOMAIN, - "veryunique", - config_entry=config_entry, - suggested_object_id="test", - ) - entity_registry.async_update_entity_options( - "vacuum.test", - vacuum.DOMAIN, - { - "area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]}, - "last_seen_segments": [ - {"id": "1", "name": "Livingroom"}, - {"id": "2", "name": "Kitchen"}, - ], - }, - ) - await mqtt_mock_entry() - # Do initial discovery - config1 = CONFIG_CLEAN_SEGMENTS_2[mqtt.DOMAIN][vacuum.DOMAIN] - payload1 = json.dumps(config1) - config_topic = "homeassistant/vacuum/bla/config" - async_fire_mqtt_message(hass, config_topic, payload1) - await hass.async_block_till_done() - state = hass.states.get("vacuum.test") - assert state.state == STATE_UNKNOWN - - issue_registry = ir.async_get(hass) - # We do not expect a repair flow - assert len(issue_registry.issues) == 0 - - # Update the segments - config2 = config1.copy() - config2["segments"] = ["1.Livingroom", "2.Kitchen", "3.Diningroom"] - payload2 = json.dumps(config2) - async_fire_mqtt_message(hass, config_topic, payload2) - await hass.async_block_till_done() - - # A repair flow should start - assert len(issue_registry.issues) == 1 - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - {"type": "vacuum/get_segments", "entity_id": "vacuum.test"} - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"]["segments"] == [ - {"id": "1", "name": "Livingroom", "group": None}, - {"id": "2", "name": "Kitchen", "group": None}, - {"id": "3", "name": "Diningroom", "group": None}, - ] - - # Test update with a non-unique segment list fails - config3 = config1.copy() - config3["segments"] = ["1.Livingroom", "2.Kitchen", "2.Diningroom"] - payload3 = json.dumps(config3) - async_fire_mqtt_message(hass, config_topic, payload3) - await hass.async_block_till_done() - assert ( - "Error 'The `segments` option contains an invalid or non-unique segment ID '2'" - in caplog.text - ) - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["Livingroom", "Kitchen", "Kitchen"], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } - }, - { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["Livingroom", "Kitchen", ""], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } - }, - { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["1.Livingroom", "1.Kitchen"], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } - }, - { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["1.Livingroom", "1.Kitchen", ".Diningroom"], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } - }, - ], -) -async def test_non_unique_segments( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test with non-unique list of cleanable segments with valid segment IDs.""" - await mqtt_mock_entry() - assert ( - "The `segments` option contains an invalid or non-unique segment ID" - in caplog.text - ) - - -@pytest.mark.usefixtures("hass") -@pytest.mark.parametrize( - ("hass_config", "error_message"), - [ - ( - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ({"clean_segments_command_topic": "test-topic"},), - ), - "Options `segments` and " - "`clean_segments_command_topic` must be defined together", - ), - ( - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ({"segments": ["Livingroom"]},), - ), - "Options `segments` and " - "`clean_segments_command_topic` must be defined together", - ), - ( - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - "segments": ["Livingroom"], - "clean_segments_command_topic": "test-topic", - }, - ), - ), - "Option `segments` requires `unique_id` to be configured", - ), - ], -) -async def test_clean_segments_config_validation( - mqtt_mock_entry: MqttMockHAClientGenerator, - error_message: str, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test status clean segment config validation.""" - await mqtt_mock_entry() - assert error_message in caplog.text - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - vacuum.DOMAIN, - CONFIG_CLEAN_SEGMENTS_2, - ({"clean_segments_command_template": "{{ ';'.join(value) }}"},), - ) - ], -) -async def test_clean_segments_command_with_id_and_command_template( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - entity_registry: er.EntityRegistry, - mqtt_mock_entry: MqttMockHAClientGenerator, -) -> None: - """Test clean segments with command template.""" - mqtt_mock = await mqtt_mock_entry() - entity_registry.async_update_entity_options( - "vacuum.test", - vacuum.DOMAIN, - { - "area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]}, - "last_seen_segments": [ - {"id": "1", "name": "Livingroom"}, - {"id": "2", "name": "Kitchen"}, - ], - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.test") - assert state.state == STATE_UNKNOWN - await common.async_clean_area( - hass, ["Livingroom", "Kitchen"], entity_id="vacuum.test" - ) - assert ( - call("vacuum/clean_segment", "1;2", 0, False) - in mqtt_mock.async_publish.mock_calls - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - {"type": "vacuum/get_segments", "entity_id": "vacuum.test"} - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"]["segments"] == [ - {"id": "1", "name": "Livingroom", "group": None}, - {"id": "2", "name": "Kitchen", "group": None}, - ] - - @pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_status( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator From 745f32faa39f9c9b3748c63d1cc029b6429748bf Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 30 Mar 2026 10:44:16 +0200 Subject: [PATCH 083/138] Update knx-frontend to 2026.3.28.223133 (#166764) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index a431ab98fef..c0a838b48c0 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.15.0", "xknxproject==3.8.2", - "knx-frontend==2026.3.2.183756" + "knx-frontend==2026.3.28.223133" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index f0013f8571d..0c9d7dd20ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1383,7 +1383,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.3.2.183756 +knx-frontend==2026.3.28.223133 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f78cfe932d7..fc6d44444f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1223,7 +1223,7 @@ kegtron-ble==1.0.2 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.3.2.183756 +knx-frontend==2026.3.28.223133 # homeassistant.components.konnected konnected==1.2.0 From 9d230b4f7c57e132130220565b9ab5d150c3157e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:13:31 +0200 Subject: [PATCH 084/138] Bump habiticalib to 0.4.7 (#166772) --- homeassistant/components/habitica/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 9649723f761..d11d6fe557b 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["habiticalib"], "quality_scale": "platinum", - "requirements": ["habiticalib==0.4.6"] + "requirements": ["habiticalib==0.4.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0c9d7dd20ea..00712b5b64e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1173,7 +1173,7 @@ ha-philipsjs==3.2.4 ha-silabs-firmware-client==0.3.0 # homeassistant.components.habitica -habiticalib==0.4.6 +habiticalib==0.4.7 # homeassistant.components.bluetooth habluetooth==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc6d44444f7..3f51c69187e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1046,7 +1046,7 @@ ha-philipsjs==3.2.4 ha-silabs-firmware-client==0.3.0 # homeassistant.components.habitica -habiticalib==0.4.6 +habiticalib==0.4.7 # homeassistant.components.bluetooth habluetooth==5.11.1 From 4168000155cc320cd0805ce9689c0e1c5f625211 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Mar 2026 08:56:27 +0000 Subject: [PATCH 085/138] Bump version to 2026.4.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 749649d7e1e..c110ace32f7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index ce332f21062..c5c0b39b021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0b4" +version = "2026.4.0b5" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 72a661f1fa9f4b56cfa3760f21457796eb64324a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Mar 2026 15:42:31 +0200 Subject: [PATCH 086/138] Improve text action naming consistency (#166523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/text/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index 0c159a8809a..e7fea2f230e 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -60,14 +60,14 @@ }, "services": { "set_value": { - "description": "Sets the value.", + "description": "Sets the value of a text entity.", "fields": { "value": { "description": "Enter your text.", "name": "Value" } }, - "name": "Set value" + "name": "Set text value" } }, "title": "Text", From cba9bf5dc43b7aece0235a233fc6ad3a6e6457a9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 10:22:31 +0100 Subject: [PATCH 087/138] Add valve conditions (#166634) --- .../components/automation/__init__.py | 1 + homeassistant/components/valve/condition.py | 20 +++ .../components/valve/conditions.yaml | 17 ++ homeassistant/components/valve/icons.json | 8 + homeassistant/components/valve/strings.json | 30 ++++ homeassistant/helpers/condition.py | 8 +- tests/components/valve/test_condition.py | 154 ++++++++++++++++++ 7 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/valve/condition.py create mode 100644 homeassistant/components/valve/conditions.yaml create mode 100644 tests/components/valve/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 787a3db179d..fcd881581d4 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -148,6 +148,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "temperature", "text", "vacuum", + "valve", "water_heater", "window", } diff --git a/homeassistant/components/valve/condition.py b/homeassistant/components/valve/condition.py new file mode 100644 index 00000000000..5ff94ee08ec --- /dev/null +++ b/homeassistant/components/valve/condition.py @@ -0,0 +1,20 @@ +"""Provides conditions for valves.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +from . import ATTR_IS_CLOSED +from .const import DOMAIN + +VALVE_DOMAIN_SPECS = {DOMAIN: DomainSpec(value_source=ATTR_IS_CLOSED)} + +CONDITIONS: dict[str, type[Condition]] = { + "is_open": make_entity_state_condition(VALVE_DOMAIN_SPECS, False), + "is_closed": make_entity_state_condition(VALVE_DOMAIN_SPECS, True), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the valve conditions.""" + return CONDITIONS diff --git a/homeassistant/components/valve/conditions.yaml b/homeassistant/components/valve/conditions.yaml new file mode 100644 index 00000000000..b639ae832e7 --- /dev/null +++ b/homeassistant/components/valve/conditions.yaml @@ -0,0 +1,17 @@ +.condition_common: &condition_common + target: + entity: + - domain: valve + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_open: *condition_common +is_closed: *condition_common diff --git a/homeassistant/components/valve/icons.json b/homeassistant/components/valve/icons.json index c9c6b632dcb..bc01ba77175 100644 --- a/homeassistant/components/valve/icons.json +++ b/homeassistant/components/valve/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "is_closed": { + "condition": "mdi:valve-closed" + }, + "is_open": { + "condition": "mdi:valve-open" + } + }, "entity_component": { "_": { "default": "mdi:valve-open", diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index 10e5e302eba..09bd02ba207 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -1,4 +1,26 @@ { + "conditions": { + "is_closed": { + "description": "Tests if one or more valves are closed.", + "fields": { + "behavior": { + "description": "Whether the condition should pass when any or all targeted entities match.", + "name": "Behavior" + } + }, + "name": "Valve is closed" + }, + "is_open": { + "description": "Tests if one or more valves are open.", + "fields": { + "behavior": { + "description": "Whether the condition should pass when any or all targeted entities match.", + "name": "Behavior" + } + }, + "name": "Valve is open" + } + }, "entity_component": { "_": { "name": "[%key:component::valve::title%]", @@ -22,6 +44,14 @@ "name": "Water" } }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + } + }, "services": { "close_valve": { "description": "Closes a valve.", diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index e71dc1b991b..5cf8df5d36c 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -421,7 +421,7 @@ class EntityConditionBase(Condition): class EntityStateConditionBase(EntityConditionBase): """State condition.""" - _states: set[str] + _states: set[str | bool] def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected state(s).""" @@ -439,7 +439,7 @@ def _normalize_domain_specs( def make_entity_state_condition( domain_specs: Mapping[str, DomainSpec] | str, - states: str | set[str], + states: str | bool | set[str | bool], ) -> type[EntityStateConditionBase]: """Create a condition for entity state changes to specific state(s). @@ -448,8 +448,8 @@ def make_entity_state_condition( """ specs = _normalize_domain_specs(domain_specs) - if isinstance(states, str): - states_set = {states} + if isinstance(states, (str, bool)): + states_set: set[str | bool] = {states} else: states_set = states diff --git a/tests/components/valve/test_condition.py b/tests/components/valve/test_condition.py new file mode 100644 index 00000000000..5ec78a90229 --- /dev/null +++ b/tests/components/valve/test_condition.py @@ -0,0 +1,154 @@ +"""Test valve conditions.""" + +from typing import Any + +import pytest + +from homeassistant.components.valve import ATTR_IS_CLOSED +from homeassistant.components.valve.const import ValveState +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_valves(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple valve entities associated with different targets.""" + return await target_entities(hass, "valve") + + +@pytest.mark.parametrize( + "condition", + [ + "valve.is_open", + "valve.is_closed", + ], +) +async def test_valve_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the valve conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("valve"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="valve.is_open", + target_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + ), + *parametrize_condition_states_any( + condition="valve.is_closed", + target_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + ), + ], +) +async def test_valve_condition_behavior_any( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test valve condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_valves, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("valve"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="valve.is_open", + target_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + ), + *parametrize_condition_states_all( + condition="valve.is_closed", + target_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + ), + ], +) +async def test_valve_condition_behavior_all( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test valve condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_valves, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) From e02a9fe61ea8d5bc2c56b351a8fae6ed6dba22a6 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Mon, 30 Mar 2026 19:17:05 +0200 Subject: [PATCH 088/138] Convert Z-Wave Opening state to separate Open/Closed and Tilted sensors (#166635) Co-authored-by: Martin Hjelmare Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/zwave_js/binary_sensor.py | 305 +++++++++- .../components/zwave_js/strings.json | 8 + .../components/zwave_js/test_binary_sensor.py | 540 +++++++++++++++++- tests/components/zwave_js/test_sensor.py | 132 ----- 4 files changed, 826 insertions(+), 159 deletions(-) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index c0df675a25d..9ec546be756 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -17,18 +17,28 @@ from zwave_js_server.const.command_class.notification import ( SmokeAlarmNotificationEvent, ) from zwave_js_server.model.driver import Driver +from zwave_js_server.model.value import Value as ZwaveValue +from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) +from homeassistant.helpers.start import async_at_started from .const import DOMAIN from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity @@ -72,8 +82,7 @@ ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR = 5632 ACCESS_CONTROL_DOOR_STATE_OPEN_TILT = 5633 -# Numeric State values used by the "Opening state" notification variable. -# This is only needed temporarily until the legacy Access Control door state binary sensors are removed. +# Numeric State values used by the Opening state notification variable. class OpeningState(IntEnum): """Opening state values exposed by Access Control notifications.""" @@ -82,23 +91,23 @@ class OpeningState(IntEnum): TILTED = 2 -# parse_opening_state helpers for the DEPRECATED legacy Access Control binary sensors. -def _legacy_is_closed(opening_state: OpeningState) -> bool: +# parse_opening_state helpers. +def _opening_state_is_closed(opening_state: OpeningState) -> bool: """Return if Opening state represents closed.""" return opening_state is OpeningState.CLOSED -def _legacy_is_open(opening_state: OpeningState) -> bool: +def _opening_state_is_open(opening_state: OpeningState) -> bool: """Return if Opening state represents open.""" return opening_state is OpeningState.OPEN -def _legacy_is_open_or_tilted(opening_state: OpeningState) -> bool: +def _opening_state_is_open_or_tilted(opening_state: OpeningState) -> bool: """Return if Opening state represents open or tilted.""" return opening_state in (OpeningState.OPEN, OpeningState.TILTED) -def _legacy_is_tilted(opening_state: OpeningState) -> bool: +def _opening_state_is_tilted(opening_state: OpeningState) -> bool: """Return if Opening state represents tilted.""" return opening_state is OpeningState.TILTED @@ -127,12 +136,51 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription): @dataclass(frozen=True, kw_only=True) class OpeningStateZWaveJSEntityDescription(BinarySensorEntityDescription): - """Describe a legacy Access Control binary sensor that derives state from Opening state.""" + """Describe an Access Control binary sensor that derives state from Opening state.""" state_key: int parse_opening_state: Callable[[OpeningState], bool] +@dataclass(frozen=True, kw_only=True) +class LegacyDoorStateRepairDescription: + """Describe how a legacy door state entity should be migrated.""" + + issue_translation_key: str + replacement_state_key: OpeningState + + +LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS: dict[str, LegacyDoorStateRepairDescription] = { + "legacy_access_control_door_state_simple_open": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open_regular": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open_tilt": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_tilt_state", + replacement_state_key=OpeningState.TILTED, + ), + "legacy_access_control_door_tilt_state_tilted": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_tilt_state", + replacement_state_key=OpeningState.TILTED, + ), +} + +LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS = frozenset( + { + description.issue_translation_key + for description in LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS.values() + } +) + + # Mappings for Notification sensors # https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx # @@ -389,6 +437,9 @@ BOOLEAN_SENSOR_MAPPINGS: dict[tuple[int, int | str], BinarySensorEntityDescripti } +# This can likely be removed once the legacy notification binary sensor +# discovery path is gone and Opening state is handled only by the dedicated +# discovery schemas below. @callback def is_valid_notification_binary_sensor( info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo, @@ -396,13 +447,111 @@ def is_valid_notification_binary_sensor( """Return if the notification CC Value is valid as binary sensor.""" if not info.primary_value.metadata.states: return False - # Access Control - Opening state is exposed as a single enum sensor instead - # of fanning out one binary sensor per state. + # Opening state is handled by dedicated discovery schemas if is_opening_state_notification_value(info.primary_value): return False return len(info.primary_value.metadata.states) > 1 +@callback +def _async_delete_legacy_entity_repairs(hass: HomeAssistant, entity_id: str) -> None: + """Delete all stale legacy door state repair issues for an entity.""" + for issue_key in LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS: + async_delete_issue(hass, DOMAIN, f"{issue_key}.{entity_id}") + + +@callback +def _async_check_legacy_entity_repair( + hass: HomeAssistant, + driver: Driver, + entity: ZWaveLegacyDoorStateBinarySensor, +) -> None: + """Schedule a repair issue check once HA has fully started.""" + + @callback + def _async_do_check(hass: HomeAssistant) -> None: + """Create or delete a repair issue for a deprecated legacy door state entity.""" + ent_reg = er.async_get(hass) + if entity.unique_id is None: + return + entity_id = ent_reg.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, entity.unique_id + ) + if entity_id is None: + return + + repair_description = LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS.get( + entity.entity_description.key + ) + if repair_description is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + entity_entry = ent_reg.async_get(entity_id) + if entity_entry is None or entity_entry.disabled: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + entity_automations = automations_with_entity(hass, entity_id) + entity_scripts = scripts_with_entity(hass, entity_id) + if not entity_automations and not entity_scripts: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + opening_state_value = get_opening_state_notification_value( + entity.info.node, entity.info.primary_value.endpoint + ) + if opening_state_value is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + replacement_unique_id = ( + f"{driver.controller.home_id}.{opening_state_value.value_id}." + f"{repair_description.replacement_state_key}" + ) + replacement_entity_id = ent_reg.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, replacement_unique_id + ) + if replacement_entity_id is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + items = [] + for domain, entity_ids in ( + ("automation", entity_automations), + ("script", entity_scripts), + ): + for eid in entity_ids: + item = ent_reg.async_get(eid) + if item: + items.append( + f"- [{item.name or item.original_name or eid}]" + f"(/config/{domain}/edit/{item.unique_id})" + ) + else: + items.append(f"- {eid}") + + async_create_issue( + hass, + DOMAIN, + f"{repair_description.issue_translation_key}.{entity_id}", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key=repair_description.issue_translation_key, + translation_placeholders={ + "entity_id": entity_id, + "entity_name": ( + entity_entry.name or entity_entry.original_name or entity_id + ), + "replacement_entity_id": replacement_entity_id, + "items": "\n".join(items), + }, + ) + + async_at_started(hass, _async_do_check) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, @@ -442,13 +591,21 @@ async def async_setup_entry( and info.entity_class is ZWaveBooleanBinarySensor ): entities.append(ZWaveBooleanBinarySensor(config_entry, driver, info)) + elif ( + isinstance(info, NewZwaveDiscoveryInfo) + and info.entity_class is ZWaveOpeningStateBinarySensor + and isinstance( + info.entity_description, OpeningStateZWaveJSEntityDescription + ) + ): + entities.append(ZWaveOpeningStateBinarySensor(config_entry, driver, info)) elif ( isinstance(info, NewZwaveDiscoveryInfo) and info.entity_class is ZWaveLegacyDoorStateBinarySensor ): - entities.append( - ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) - ) + entity = ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) + entities.append(entity) + _async_check_legacy_entity_repair(hass, driver, entity) elif isinstance(info, NewZwaveDiscoveryInfo): pass # other entity classes are not migrated yet elif info.platform_hint == "notification": @@ -632,6 +789,69 @@ class ZWaveLegacyDoorStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity): return None +class ZWaveOpeningStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity): + """Representation of a binary sensor derived from Opening state.""" + + entity_description: OpeningStateZWaveJSEntityDescription + _known_states: set[str] + + def __init__( + self, + config_entry: ZwaveJSConfigEntry, + driver: Driver, + info: NewZwaveDiscoveryInfo, + ) -> None: + """Initialize an Opening state binary sensor entity.""" + super().__init__(config_entry, driver, info) + self._known_states = set(info.primary_value.metadata.states or ()) + self._attr_unique_id = ( + f"{self._attr_unique_id}.{self.entity_description.state_key}" + ) + + @callback + def should_rediscover_on_metadata_update(self) -> bool: + """Check if metadata states require adding the Tilt entity.""" + return ( + # Open and Tilt entities share the same underlying Opening state value. + # Only let the main Open entity trigger rediscovery when Tilt first + # appears so we can add the missing sibling without recreating the + # main entity and losing its registry customizations. + str(OpeningState.TILTED) not in self._known_states + and str(OpeningState.TILTED) + in set(self.info.primary_value.metadata.states or ()) + and self.entity_description.state_key == OpeningState.OPEN + ) + + async def _async_remove_and_rediscover(self, value: ZwaveValue) -> None: + """Trigger re-discovery while preserving the main Opening state entity.""" + assert self.device_entry is not None + controller_events = ( + self.config_entry.runtime_data.driver_events.controller_events + ) + + # Unlike the base implementation, keep this entity in place so its + # registry entry and user customizations survive metadata rediscovery. + controller_events.discovered_value_ids[self.device_entry.id].discard( + value.value_id + ) + node_events = controller_events.node_events + value_updates_disc_info = node_events.value_updates_disc_info[ + value.node.node_id + ] + node_events.async_on_value_added(value_updates_disc_info, value) + + @property + def is_on(self) -> bool | None: + """Return if the sensor is on or off.""" + value = self.info.primary_value.value + if value is None: + return None + try: + return self.entity_description.parse_opening_state(OpeningState(int(value))) + except TypeError, ValueError: + return None + + class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): """Representation of a Z-Wave binary_sensor from a property.""" @@ -730,11 +950,54 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ ), entity_class=ZWaveNotificationBinarySensor, ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Opening state"}, + type={ValueType.NUMBER}, + any_available_states_keys={OpeningState.TILTED}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + # Also derive the main binary sensor from the same value ID + allow_multi=True, + entity_description=OpeningStateZWaveJSEntityDescription( + key="access_control_opening_state_tilted", + name="Tilt", + state_key=OpeningState.TILTED, + parse_opening_state=_opening_state_is_tilted, + ), + entity_class=ZWaveOpeningStateBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Opening state"}, + type={ValueType.NUMBER}, + any_available_states_keys={OpeningState.OPEN}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + entity_description=OpeningStateZWaveJSEntityDescription( + key="access_control_opening_state_open", + state_key=OpeningState.OPEN, + parse_opening_state=_opening_state_is_open_or_tilted, + device_class=BinarySensorDeviceClass.DOOR, + ), + entity_class=ZWaveOpeningStateBinarySensor, + ), # ------------------------------------------------------------------- # DEPRECATED legacy Access Control door/window binary sensors. # These schemas exist only for backwards compatibility with users who # already have these entities registered. New integrations should use - # the Opening state enum sensor instead. Do not add new schemas here. + # the dedicated Opening state binary sensors instead. Do not add new + # schemas here. # All schemas below use ZWaveLegacyDoorStateBinarySensor and are # disabled by default (entity_registry_enabled_default=False). # ------------------------------------------------------------------- @@ -758,7 +1021,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_simple_open", name="Window/door is open", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN, - parse_opening_state=_legacy_is_open_or_tilted, + parse_opening_state=_opening_state_is_open_or_tilted, device_class=BinarySensorDeviceClass.DOOR, entity_registry_enabled_default=False, ), @@ -784,7 +1047,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_simple_closed", name="Window/door is closed", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED, - parse_opening_state=_legacy_is_closed, + parse_opening_state=_opening_state_is_closed, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -809,7 +1072,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_open", name="Window/door is open", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN, - parse_opening_state=_legacy_is_open, + parse_opening_state=_opening_state_is_open, device_class=BinarySensorDeviceClass.DOOR, entity_registry_enabled_default=False, ), @@ -835,7 +1098,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_closed", name="Window/door is closed", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED, - parse_opening_state=_legacy_is_closed, + parse_opening_state=_opening_state_is_closed, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -858,7 +1121,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_open_regular", name="Window/door is open in regular position", state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR, - parse_opening_state=_legacy_is_open, + parse_opening_state=_opening_state_is_open, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -881,7 +1144,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_open_tilt", name="Window/door is open in tilt position", state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_TILT, - parse_opening_state=_legacy_is_tilted, + parse_opening_state=_opening_state_is_tilted, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -904,7 +1167,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_tilt_state_tilted", name="Window/door is tilted", state_key=OpeningState.OPEN, - parse_opening_state=_legacy_is_tilted, + parse_opening_state=_opening_state_is_tilted, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index dbaefc4f1cf..cc933386d13 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -303,6 +303,14 @@ } }, "issues": { + "deprecated_legacy_door_open_state": { + "description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the binary sensor `{replacement_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the binary sensor `{replacement_entity_id}` and disable the binary sensor `{entity_id}` and then restart Home Assistant, to fix this issue.\n\nNote that `{replacement_entity_id}` is on when the door or window is open or tilted.", + "title": "Deprecation: {entity_name}" + }, + "deprecated_legacy_door_tilt_state": { + "description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the binary sensor `{replacement_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the binary sensor `{replacement_entity_id}` and disable the binary sensor `{entity_id}` and then restart Home Assistant, to fix this issue.\n\nNote that `{replacement_entity_id}` is on only when the door or window is tilted.", + "title": "Deprecation: {entity_name}" + }, "device_config_file_changed": { "fix_flow": { "abort": { diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index ad7db02950c..dd8001d4cfd 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -9,7 +9,12 @@ import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node -from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components import automation +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -20,7 +25,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import ( @@ -144,6 +151,22 @@ def _add_lock_state_notification_states(node_state: dict[str, Any]) -> dict[str, return updated_state +def _set_opening_state_metadata_states( + node_state: dict[str, Any], states: dict[str, str] +) -> dict[str, Any]: + """Return a node state with updated Opening state metadata states.""" + updated_state = copy.deepcopy(node_state) + for value_data in updated_state["values"]: + if ( + value_data.get("commandClass") == 113 + and value_data.get("property") == "Access Control" + and value_data.get("propertyKey") == "Opening state" + ): + value_data["metadata"]["states"] = states + break + return updated_state + + @pytest.fixture def platforms() -> list[str]: """Fixture to specify platforms to test.""" @@ -418,12 +441,12 @@ async def test_property_sensor_door_status( assert state.state == STATE_UNKNOWN -async def test_opening_state_notification_does_not_create_binary_sensors( +async def test_opening_state_creates_open_binary_sensor( hass: HomeAssistant, client, hoppe_ehandle_connectsense_state, ) -> None: - """Test Opening state does not fan out into per-state binary sensors.""" + """Test Opening state creates the Open binary sensor.""" # The eHandle fixture has a Binary Sensor CC value for tilt, which we # want to ignore in the assertion below state = copy.deepcopy(hoppe_ehandle_connectsense_state) @@ -440,7 +463,12 @@ async def test_opening_state_notification_does_not_create_binary_sensors( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert not hass.states.async_all("binary_sensor") + open_state = hass.states.get("binary_sensor.ehandle_connectsense") + assert open_state is not None + assert open_state.state == STATE_OFF + assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR + + assert hass.states.get("binary_sensor.ehandle_connectsense_tilt") is None async def test_opening_state_disables_legacy_window_door_notification_sensors( @@ -476,7 +504,7 @@ async def test_opening_state_disables_legacy_window_door_notification_sensors( } or ( entry.original_name == "Window/door is tilted" - and entry.original_device_class != BinarySensorDeviceClass.WINDOW + and entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION ) ) ] @@ -488,6 +516,162 @@ async def test_opening_state_disables_legacy_window_door_notification_sensors( ) assert all(hass.states.get(entry.entity_id) is None for entry in legacy_entries) + open_state = hass.states.get("binary_sensor.ehandle_connectsense") + assert open_state is not None + assert open_state.state == STATE_OFF + assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR + + +async def test_opening_state_binary_sensors_with_tilted( + hass: HomeAssistant, + client, + hoppe_ehandle_connectsense_state, +) -> None: + """Test Opening state creates Open and Tilt binary sensors when supported.""" + node = Node( + client, + _set_opening_state_metadata_states( + hoppe_ehandle_connectsense_state, + {"0": "Closed", "1": "Open", "2": "Tilted"}, + ), + ) + client.driver.controller.nodes[node.node_id] = node + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + open_entity_id = "binary_sensor.ehandle_connectsense" + tilted_entity_id = "binary_sensor.ehandle_connectsense_tilt" + + open_state = hass.states.get(open_entity_id) + tilted_state = hass.states.get(tilted_entity_id) + assert open_state is not None + assert tilted_state is not None + assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR + assert ATTR_DEVICE_CLASS not in tilted_state.attributes + assert open_state.state == STATE_OFF + assert tilted_state.state == STATE_OFF + + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Access Control", + "propertyKey": "Opening state", + "newValue": 1, + "prevValue": 0, + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + }, + }, + ) + ) + await hass.async_block_till_done() + + assert hass.states.get(open_entity_id).state == STATE_ON + assert hass.states.get(tilted_entity_id).state == STATE_OFF + + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Access Control", + "propertyKey": "Opening state", + "newValue": 2, + "prevValue": 1, + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + }, + }, + ) + ) + await hass.async_block_till_done() + + assert hass.states.get(open_entity_id).state == STATE_ON + assert hass.states.get(tilted_entity_id).state == STATE_ON + + +async def test_opening_state_tilted_appears_via_metadata_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + hoppe_ehandle_connectsense_state, +) -> None: + """Test tilt binary sensor is added without recreating the main entity.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + open_entity_id = "binary_sensor.ehandle_connectsense" + tilted_entity_id = "binary_sensor.ehandle_connectsense_tilt" + open_entry = entity_registry.async_get(open_entity_id) + assert open_entry is not None + + assert hass.states.get(open_entity_id) is not None + assert hass.states.get(tilted_entity_id) is None + + node.receive_event( + Event( + "metadata updated", + { + "source": "node", + "event": "metadata updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Access Control", + "propertyKey": "Opening state", + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Opening state", + "ccSpecific": {"notificationType": 6}, + "min": 0, + "max": 255, + "states": { + "0": "Closed", + "1": "Open", + "2": "Tilted", + }, + "stateful": True, + "secret": False, + }, + }, + }, + ) + ) + await hass.async_block_till_done() + + assert hass.states.get(open_entity_id) is not None + tilted_state = hass.states.get(tilted_entity_id) + assert tilted_state is not None + assert entity_registry.async_get(open_entity_id) == open_entry + async def test_reenabled_legacy_door_state_entity_follows_opening_state( hass: HomeAssistant, @@ -983,3 +1167,347 @@ async def test_hoppe_ehandle_connectsense( assert entry.original_name == "Window/door is tilted" assert entry.original_device_class == BinarySensorDeviceClass.WINDOW assert entry.disabled_by is None, "Entity should be enabled by default" + + +async def test_legacy_door_open_state_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test an open-state legacy entity creates the open-state repair issue.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.22", + suggested_object_id="ehandle_connectsense_window_door_is_open", + original_name="Window/door is open", + ) + entity_id = entity_entry.entity_id + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + assert issue is not None + assert issue.translation_key == "deprecated_legacy_door_open_state" + assert issue.translation_placeholders["entity_id"] == entity_id + assert issue.translation_placeholders["entity_name"] == "Window/door is open" + assert ( + issue.translation_placeholders["replacement_entity_id"] + == "binary_sensor.ehandle_connectsense" + ) + assert "test" in issue.translation_placeholders["items"] + + +async def test_legacy_door_tilt_state_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test a tilt-state legacy entity creates the tilt-state repair issue.""" + node = Node( + client, + _set_opening_state_metadata_states( + hoppe_ehandle_connectsense_state, + {"0": "Closed", "1": "Open", "2": "Tilted"}, + ), + ) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.5633", + suggested_object_id="ehandle_connectsense_window_door_is_open_in_tilt_position", + original_name="Window/door is open in tilt position", + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}" + ) + assert issue is not None + assert issue.translation_key == "deprecated_legacy_door_tilt_state" + assert issue.translation_placeholders["entity_id"] == entity_id + assert ( + issue.translation_placeholders["entity_name"] + == "Window/door is open in tilt position" + ) + assert ( + issue.translation_placeholders["replacement_entity_id"] + == "binary_sensor.ehandle_connectsense_tilt" + ) + assert "test" in issue.translation_placeholders["items"] + + +async def test_legacy_door_open_state_no_repair_issue_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test no repair issue is created when the legacy entity is disabled.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.22", + suggested_object_id="ehandle_connectsense_window_door_is_open", + original_name="Window/door is open", + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) + + +async def test_legacy_closed_door_state_does_not_create_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test closed-state legacy entities are excluded from repair issues.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.23", + suggested_object_id="ehandle_connectsense_window_door_is_closed", + original_name="Window/door is closed", + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}" + ) + is None + ) + + +async def test_hoppe_custom_tilt_sensor_no_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test no repair issue for the custom Binary Sensor CC tilt entity.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-48-0-Tilt", + suggested_object_id="ehandle_connectsense_window_door_is_tilted", + original_name="Window/door is tilted", + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}" + ) + is None + ) + + +async def test_legacy_door_open_state_stale_repair_issue_cleaned_up( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test stale open-state repair issues are deleted when no references remain.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.22", + suggested_object_id="ehandle_connectsense_window_door_is_open", + original_name="Window/door is open", + ) + entity_id = entity_entry.entity_id + + async_create_issue( + hass, + DOMAIN, + f"deprecated_legacy_door_open_state.{entity_id}", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_legacy_door_open_state", + translation_placeholders={ + "entity_id": entity_id, + "entity_name": "Window/door is open", + "replacement_entity_id": "binary_sensor.ehandle_connectsense", + "items": "- [test](/config/automation/edit/test_automation)", + }, + ) + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is not None + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index e111d6aed91..e5b7d40f712 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -11,7 +11,6 @@ from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node from homeassistant.components.sensor import ( - ATTR_OPTIONS, ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, @@ -895,137 +894,6 @@ async def test_new_sensor_invalid_scale( mock_schedule_reload.assert_called_once_with(integration.entry_id) -async def test_opening_state_sensor( - hass: HomeAssistant, - client, - hoppe_ehandle_connectsense_state, -) -> None: - """Test Opening state is exposed as an enum sensor.""" - node = Node(client, hoppe_ehandle_connectsense_state) - client.driver.controller.nodes[node.node_id] = node - - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("sensor.ehandle_connectsense_opening_state") - assert state - assert state.state == "Closed" - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"] - assert state.attributes[ATTR_VALUE] == 0 - - # Make sure we're not accidentally creating enum sensors for legacy - # Door/Window notification variables. - legacy_sensor_ids = [ - "sensor.ehandle_connectsense_door_state", - "sensor.ehandle_connectsense_door_state_simple", - ] - for entity_id in legacy_sensor_ids: - assert hass.states.get(entity_id) is None - - -async def test_opening_state_sensor_metadata_options_change( - hass: HomeAssistant, - hoppe_ehandle_connectsense: Node, - integration: MockConfigEntry, -) -> None: - """Test Opening state sensor is rediscovered when metadata options change.""" - entity_id = "sensor.ehandle_connectsense_opening_state" - node = hoppe_ehandle_connectsense - - # Verify initial state with 2 options - state = hass.states.get(entity_id) - assert state - assert state.state == "Closed" - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"] - - # Simulate metadata update adding "Tilted" state - event = Event( - "metadata updated", - { - "source": "node", - "event": "metadata updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Notification", - "commandClass": 113, - "endpoint": 0, - "property": "Access Control", - "propertyKey": "Opening state", - "propertyName": "Access Control", - "propertyKeyName": "Opening state", - "metadata": { - "type": "number", - "readable": True, - "writeable": False, - "label": "Opening state", - "ccSpecific": {"notificationType": 6}, - "min": 0, - "max": 255, - "states": { - "0": "Closed", - "1": "Open", - "2": "Tilted", - }, - "stateful": True, - "secret": False, - }, - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - # Entity should be rediscovered with 3 options - state = hass.states.get(entity_id) - assert state - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open", "Tilted"] - - # Simulate metadata update removing "Tilted" state - event = Event( - "metadata updated", - { - "source": "node", - "event": "metadata updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Notification", - "commandClass": 113, - "endpoint": 0, - "property": "Access Control", - "propertyKey": "Opening state", - "propertyName": "Access Control", - "propertyKeyName": "Opening state", - "metadata": { - "type": "number", - "readable": True, - "writeable": False, - "label": "Opening state", - "ccSpecific": {"notificationType": 6}, - "min": 0, - "max": 255, - "states": { - "0": "Closed", - "1": "Open", - }, - "stateful": True, - "secret": False, - }, - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - # Entity should be rediscovered with 2 options again - state = hass.states.get(entity_id) - assert state - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"] - - CONTROLLER_STATISTICS_ENTITY_PREFIX = "sensor.z_stick_gen5_usb_controller_" # controller statistics with initial state of 0 CONTROLLER_STATISTICS_SUFFIXES = { From 6111eaa9e9f301e6a1135ddd0f9fbcf411d71bbe Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Mon, 30 Mar 2026 10:58:11 -0400 Subject: [PATCH 089/138] Support vacation mode in Econet (#166659) --- .../components/econet/water_heater.py | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index 876d9270bc9..450c2b5eaa7 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -45,6 +45,13 @@ SUPPORT_FLAGS_HEATER = ( ) +def _operation_mode_to_ha(mode: WaterHeaterOperationMode | None) -> str: + """Translate an EcoNet operation mode to a Home Assistant state.""" + if mode in (None, WaterHeaterOperationMode.VACATION): + return STATE_OFF + return ECONET_STATE_TO_HA[mode] + + async def async_setup_entry( hass: HomeAssistant, entry: EconetConfigEntry, @@ -80,26 +87,22 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity): @property def current_operation(self) -> str: """Return current operation.""" - econet_mode = self.water_heater.mode - _current_op = STATE_OFF - if econet_mode is not None: - _current_op = ECONET_STATE_TO_HA[econet_mode] - - return _current_op + return _operation_mode_to_ha(self.water_heater.mode) @property def operation_list(self) -> list[str]: """List of available operation modes.""" - econet_modes = self.water_heater.modes - operation_modes = set() - for mode in econet_modes: - if ( - mode is not WaterHeaterOperationMode.UNKNOWN - and mode is not WaterHeaterOperationMode.VACATION - ): - ha_mode = ECONET_STATE_TO_HA[mode] - operation_modes.add(ha_mode) - return list(operation_modes) + return list( + dict.fromkeys( + ECONET_STATE_TO_HA[mode] + for mode in self.water_heater.modes + if mode + not in ( + WaterHeaterOperationMode.UNKNOWN, + WaterHeaterOperationMode.VACATION, + ) + ) + ) @property def supported_features(self) -> WaterHeaterEntityFeature: From 7170e3b23202b3c752c88985acaa19f845ca1134 Mon Sep 17 00:00:00 2001 From: Florian Date: Mon, 30 Mar 2026 12:34:38 +0200 Subject: [PATCH 090/138] Clamp surepetcare battery percentage to 0-100 (#166824) Co-authored-by: Claude --- homeassistant/components/surepetcare/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 6f7dc6a33e9..a34675eee74 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -73,8 +73,8 @@ class SureBattery(SurePetcareEntity, SensorEntity): try: per_battery_voltage = state["battery"] / 4 voltage_diff = per_battery_voltage - SURE_BATT_VOLTAGE_LOW - self._attr_native_value = min( - int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100 + self._attr_native_value = max( + 0, min(int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100) ) except KeyError, TypeError: self._attr_native_value = None From ff083358901f8bac944bc5be916017eafaf91ca5 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 30 Mar 2026 17:27:39 +0300 Subject: [PATCH 091/138] Fix OpenAI image generation with reasoning (#166827) --- .../components/openai_conversation/entity.py | 8 +++++++- .../components/openai_conversation/test_ai_task.py | 13 ++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 399da7ce4d8..50a4f6f8f7e 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -346,7 +346,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have id=event.item.id, tool_name="web_search_call", tool_args={ - "action": event.item.action.to_dict(), + "action": event.item.action.to_dict() + if event.item.action + else None, }, external=True, ) @@ -360,6 +362,10 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have } last_role = "tool_result" elif isinstance(event.item, ImageGenerationCall): + if last_summary_index is not None: + yield {"role": "assistant"} + last_role = "assistant" + last_summary_index = None yield {"native": event.item} last_summary_index = -1 # Trigger new assistant message on next turn elif isinstance(event, ResponseTextDeltaEvent): diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py index 783efcee40c..990c4322a04 100644 --- a/tests/components/openai_conversation/test_ai_task.py +++ b/tests/components/openai_conversation/test_ai_task.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir, selector -from . import create_image_gen_call_item, create_message_item +from . import create_image_gen_call_item, create_message_item, create_reasoning_item from tests.common import MockConfigEntry @@ -247,8 +247,15 @@ async def test_generate_image( # Mock the OpenAI response stream mock_create_stream.return_value = [ - create_image_gen_call_item(id="ig_A", output_index=0), - create_message_item(id="msg_A", text="", output_index=1), + ( + *create_reasoning_item( + id="rs_A", + output_index=0, + reasoning_summary=[["The user asks me to generate an image"]], + ), + *create_image_gen_call_item(id="ig_A", output_index=1), + *create_message_item(id="msg_A", text="", output_index=2), + ) ] with patch.object( From 425d380d033ad32b4ad749cdda39f3598294ae79 Mon Sep 17 00:00:00 2001 From: Lorenzo Gasparini <112936414+Lorenzo-Gasparini@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:33:00 +0200 Subject: [PATCH 092/138] Bump fing_agent_api to 1.1.0 (#166855) --- homeassistant/components/fing/config_flow.py | 2 ++ homeassistant/components/fing/coordinator.py | 2 ++ homeassistant/components/fing/manifest.json | 2 +- homeassistant/components/fing/quality_scale.yaml | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fing/config_flow.py b/homeassistant/components/fing/config_flow.py index 0c99f7e34db..10dd6bbb3f8 100644 --- a/homeassistant/components/fing/config_flow.py +++ b/homeassistant/components/fing/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT +from homeassistant.helpers.httpx_client import get_async_client from .const import DOMAIN, UPNP_AVAILABLE @@ -40,6 +41,7 @@ class FingConfigFlow(ConfigFlow, domain=DOMAIN): ip=user_input[CONF_IP_ADDRESS], port=int(user_input[CONF_PORT]), key=user_input[CONF_API_KEY], + client=get_async_client(self.hass), ) try: diff --git a/homeassistant/components/fing/coordinator.py b/homeassistant/components/fing/coordinator.py index 84d44ce5d73..b2390f77317 100644 --- a/homeassistant/components/fing/coordinator.py +++ b/homeassistant/components/fing/coordinator.py @@ -11,6 +11,7 @@ import httpx from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, UPNP_AVAILABLE @@ -38,6 +39,7 @@ class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]): ip=config_entry.data[CONF_IP_ADDRESS], port=int(config_entry.data[CONF_PORT]), key=config_entry.data[CONF_API_KEY], + client=get_async_client(hass), ) self._upnp_available = config_entry.data[UPNP_AVAILABLE] update_interval = timedelta(seconds=30) diff --git a/homeassistant/components/fing/manifest.json b/homeassistant/components/fing/manifest.json index af2fb867039..32978274200 100644 --- a/homeassistant/components/fing/manifest.json +++ b/homeassistant/components/fing/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["fing_agent_api==1.0.3"] + "requirements": ["fing_agent_api==1.1.0"] } diff --git a/homeassistant/components/fing/quality_scale.yaml b/homeassistant/components/fing/quality_scale.yaml index 273190261d7..443ae2499c9 100644 --- a/homeassistant/components/fing/quality_scale.yaml +++ b/homeassistant/components/fing/quality_scale.yaml @@ -68,5 +68,5 @@ rules: # Platinum async-dependency: todo - inject-websession: todo + inject-websession: done strict-typing: todo diff --git a/requirements_all.txt b/requirements_all.txt index 00712b5b64e..16fc952ff99 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -963,7 +963,7 @@ feedparser==6.0.12 file-read-backwards==2.0.0 # homeassistant.components.fing -fing_agent_api==1.0.3 +fing_agent_api==1.1.0 # homeassistant.components.fints fints==3.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f51c69187e..5561fb8b317 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -854,7 +854,7 @@ feedparser==6.0.12 file-read-backwards==2.0.0 # homeassistant.components.fing -fing_agent_api==1.0.3 +fing_agent_api==1.1.0 # homeassistant.components.fints fints==3.1.0 From 1355958f539354346b69546b29e6bacf1efbdbd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Mon, 30 Mar 2026 17:03:21 +0200 Subject: [PATCH 093/138] Skip unavailable sensors in LaCrosse View (#166859) --- .../components/lacrosse_view/coordinator.py | 50 ++++++++++++------- tests/components/lacrosse_view/test_sensor.py | 50 ++++++++++++++++++- 2 files changed, 81 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index c6f3c2312c0..3d5e3bf4ce0 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -73,31 +73,45 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): except HTTPError as error: raise UpdateFailed from error - try: - # Fetch last hour of data - for sensor in self.devices: + # Fetch last hour of data + for sensor in self.devices: + try: data = await self.api.get_sensor_status( sensor=sensor, tz=self.hass.config.time_zone, ) - _LOGGER.debug("Got data: %s", data) + except HTTPError as error: + error_data = error.args[1] if len(error.args) > 1 else None + if ( + isinstance(error_data, dict) + and error_data.get("error") == "no_readings" + ): + sensor.data = None + _LOGGER.debug("No readings for %s", sensor.name) + continue + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_error" + ) from error - if data_error := data.get("error"): - if data_error == "no_readings": - sensor.data = None - _LOGGER.debug("No readings for %s", sensor.name) - continue - _LOGGER.debug("Error: %s", data_error) - raise UpdateFailed( - translation_domain=DOMAIN, translation_key="update_error" - ) + _LOGGER.debug("Got data: %s", data) - sensor.data = data["data"]["current"] + if data_error := data.get("error"): + if data_error == "no_readings": + sensor.data = None + _LOGGER.debug("No readings for %s", sensor.name) + continue + _LOGGER.debug("Error: %s", data_error) + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_error" + ) - except HTTPError as error: - raise UpdateFailed( - translation_domain=DOMAIN, translation_key="update_error" - ) from error + current_data = data.get("data", {}).get("current") + if current_data is None: + sensor.data = None + _LOGGER.debug("No current data payload for %s", sensor.name) + continue + + sensor.data = current_data # Verify that we have permission to read the sensors for sensor in self.devices: diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index f0860f47b01..361bb9cf1ce 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import patch -from lacrosse_view import Sensor +from lacrosse_view import HTTPError, Sensor import pytest from homeassistant.components.lacrosse_view.const import DOMAIN @@ -230,6 +230,54 @@ async def test_no_readings(hass: HomeAssistant) -> None: assert hass.states.get("sensor.test_temperature").state == "unavailable" +async def test_mixed_readings(hass: HomeAssistant) -> None: + """Test a device without readings does not fail setup for the whole entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry.add_to_hass(hass) + + working_sensor = TEST_SENSOR.model_copy( + update={"name": "Working", "sensor_id": "working", "device_id": "working"} + ) + no_readings_sensor = TEST_NO_READINGS_SENSOR.model_copy( + update={ + "name": "No readings", + "sensor_id": "no_readings", + "device_id": "no_readings", + } + ) + working_status = working_sensor.data + no_readings_status = no_readings_sensor.data + working_sensor.data = None + no_readings_sensor.data = None + + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch( + "lacrosse_view.LaCrosse.get_devices", + return_value=[working_sensor, no_readings_sensor], + ), + patch( + "lacrosse_view.LaCrosse.get_sensor_status", + side_effect=[ + working_status, + HTTPError( + "Failed to get sensor status, status code: 404", + no_readings_status, + ), + ], + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert hass.states.get("sensor.working_temperature").state == "2" + assert hass.states.get("sensor.no_readings_temperature").state == "unavailable" + + async def test_other_error(hass: HomeAssistant) -> None: """Test behavior when there is an error.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) From 414756edc4dbd09848d9e848f7f25156089ad187 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 30 Mar 2026 14:29:25 +0200 Subject: [PATCH 094/138] Get list of analytics insights integrations from next environment (#166867) --- homeassistant/components/analytics_insights/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index 3691cab2300..b0973956c4e 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from python_homeassistant_analytics import ( + Environment, HomeassistantAnalyticsClient, HomeassistantAnalyticsConnectionError, ) @@ -38,7 +39,7 @@ async def async_setup_entry( client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass)) try: - integrations = await client.get_integrations() + integrations = await client.get_integrations(Environment.NEXT) except HomeassistantAnalyticsConnectionError as ex: raise ConfigEntryNotReady("Could not fetch integration list") from ex From 8ee0b97e5fd1f1a164519b03fef276c97b0f58ad Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:08:21 +0200 Subject: [PATCH 095/138] Unprefix entity name for template function (#166899) --- homeassistant/helpers/entity_registry.py | 21 ++++++++++++ homeassistant/helpers/template/__init__.py | 2 +- tests/helpers/template/test_init.py | 37 +++++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 851ab2c8990..45251dc9d02 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -458,6 +458,27 @@ class RegistryEntry: hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs) +@callback +def async_get_unprefixed_name(hass: HomeAssistant, entry: RegistryEntry) -> str: + """Get the entity name with device name prefix stripped, if applicable.""" + name = entry.name + if name is not None: + if ( + entry.device_id is not None + and (device := dr.async_get(hass).async_get(entry.device_id)) is not None + ): + device_name = device.name_by_user or device.name + unprefixed_name = _async_strip_prefix_from_entity_name(name, device_name) + if unprefixed_name is not None: + return unprefixed_name + return name + + if entry.original_name_unprefixed is not None: + return entry.original_name_unprefixed + + return entry.original_name or "" + + @callback def _async_get_full_entity_name( hass: HomeAssistant, diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index df033135460..22a476fb941 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -1409,7 +1409,7 @@ def entity_name(hass: HomeAssistant, entity_id: str) -> str | None: """Get the name of an entity from its entity ID.""" ent_reg = er.async_get(hass) if (entry := ent_reg.async_get(entity_id)) is not None: - return entry.name if entry.name is not None else entry.original_name + return er.async_get_unprefixed_name(hass, entry) # Fall back to state for entities without a unique_id (not in the registry) if (state := hass.states.get(entity_id)) is not None: diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 0b24953ea58..30846eef202 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -33,7 +33,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError -from homeassistant.helpers import entity, entity_registry as er, template, translation +from homeassistant.helpers import ( + device_registry as dr, + entity, + entity_registry as er, + template, + translation, +) from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.json import json_dumps from homeassistant.helpers.template.render_info import ( @@ -810,6 +816,7 @@ def test_if_state_exists(hass: HomeAssistant) -> None: def test_entity_name( hass: HomeAssistant, entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test entity_name method.""" assert render(hass, "{{ entity_name('sensor.fake') }}") is None @@ -837,6 +844,34 @@ def test_entity_name( "No Unique ID Light" ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="My Device", + ) + entry2 = entity_registry.async_get_or_create( + "sensor", + "test", + "unique_2", + config_entry=config_entry, + device_id=device_entry.id, + has_entity_name=True, + original_name="Temperature", + ) + assert render(hass, f"{{{{ entity_name('{entry2.entity_id}') }}}}") == ( + "Temperature" + ) + + # Strips device name prefix + entity_registry.async_update_entity( + entry2.entity_id, name="My Device Custom Sensor" + ) + assert render(hass, f"{{{{ entity_name('{entry2.entity_id}') }}}}") == ( + "Custom Sensor" + ) + def test_is_hidden_entity( hass: HomeAssistant, From 06e8333eab4ecf02cf0453b60dd22e1ef93f6f50 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:05:39 +0200 Subject: [PATCH 096/138] Unprefix entity name for entity ID generation (#166900) --- homeassistant/helpers/entity_registry.py | 6 ++ tests/helpers/test_entity_registry.py | 88 ++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 45251dc9d02..1bcf61a3cf9 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -490,6 +490,7 @@ def _async_get_full_entity_name( original_name: str | None, original_name_unprefixed: str | None | UndefinedType = UNDEFINED, overridden_name: str | None = None, + unprefix_name: bool = False, use_legacy_naming: bool = False, ) -> str: """Get full name for an entity. @@ -521,6 +522,10 @@ def _async_get_full_entity_name( if original_name_unprefixed is not None else original_name ) + elif unprefix_name: + unprefixed_name = _async_strip_prefix_from_entity_name(name, device_name) + if unprefixed_name is not None: + name = unprefixed_name if not name: name = device_name @@ -1256,6 +1261,7 @@ class EntityRegistry(BaseRegistry): name=name, original_name=object_id_base, overridden_name=suggested_object_id, + unprefix_name=True, ) return self.async_get_available_entity_id( domain, diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 94f330f63e2..4ffb819d0e2 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -571,6 +571,94 @@ def test_get_available_entity_id_considers_existing_entities( ) +@pytest.mark.parametrize( + ( + "device_name", + "object_id_base", + "suggested_object_id", + "user_name", + "expected_entity_id", + ), + [ + ( + None, + "My Sensor", + None, + None, + "sensor.my_sensor", + ), + ( + "Living Room", + "Temperature", + None, + None, + "sensor.living_room_temperature", + ), + ( + "Living Room", + "Temperature", + "custom_id", + None, + "sensor.custom_id", + ), + ( + "Living Room", + "Temperature", + "custom_id", + "Humidity", + "sensor.living_room_humidity", + ), + ( + "Living Room", + "Temperature", + None, + "Living Room Sensor", + "sensor.living_room_sensor", + ), + ], +) +def test_regenerate_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_name: str | None, + object_id_base: str | None, + suggested_object_id: str | None, + user_name: str | None, + expected_entity_id: str, +) -> None: + """Test regenerating entity IDs.""" + config_entry = MockConfigEntry(domain="sensor") + config_entry.add_to_hass(hass) + + device_id: str | None = None + if device_name is not None: + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name=device_name, + ) + device_id = device_entry.id + + entry = entity_registry.async_get_or_create( + "sensor", + "test", + "1234", + config_entry=config_entry, + device_id=device_id, + has_entity_name=True, + object_id_base=object_id_base, + original_name=object_id_base, + suggested_object_id=suggested_object_id, + ) + + if user_name is not None: + entry = entity_registry.async_update_entity(entry.entity_id, name=user_name) + + new_entity_id = entity_registry.async_regenerate_entity_id(entry) + assert new_entity_id == expected_entity_id + + def test_is_registered(entity_registry: er.EntityRegistry) -> None: """Test that is_registered works.""" entry = entity_registry.async_get_or_create("light", "hue", "1234") From 3a81eb9552a31844988d5b693b5c4c92f8e83a58 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:23:22 -0400 Subject: [PATCH 097/138] Bump victron-ble-ha-parser (#166906) --- homeassistant/components/victron_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/victron_ble/manifest.json b/homeassistant/components/victron_ble/manifest.json index 85455f039e9..3a5ea6222a2 100644 --- a/homeassistant/components/victron_ble/manifest.json +++ b/homeassistant/components/victron_ble/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["victron-ble-ha-parser==0.6.2"] + "requirements": ["victron-ble-ha-parser==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 16fc952ff99..9d6c677709c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3234,7 +3234,7 @@ venstarcolortouch==0.21 viaggiatreno_ha==0.2.4 # homeassistant.components.victron_ble -victron-ble-ha-parser==0.6.2 +victron-ble-ha-parser==0.6.3 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5561fb8b317..5ebaee0739d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2737,7 +2737,7 @@ velbus-aio==2026.2.0 venstarcolortouch==0.21 # homeassistant.components.victron_ble -victron-ble-ha-parser==0.6.2 +victron-ble-ha-parser==0.6.3 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 From 29980d69b56e4b0cf522521d0147bda3d8ba652c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:06:43 +0200 Subject: [PATCH 098/138] Add `valve.opened` and `valve.closed` triggers (#165160) --- .../components/automation/__init__.py | 1 + homeassistant/components/valve/icons.json | 8 + homeassistant/components/valve/strings.json | 35 +++- homeassistant/components/valve/trigger.py | 24 +++ homeassistant/components/valve/triggers.yaml | 18 ++ homeassistant/helpers/trigger.py | 8 +- tests/components/valve/test_trigger.py | 161 ++++++++++++++++++ 7 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/valve/trigger.py create mode 100644 homeassistant/components/valve/triggers.yaml create mode 100644 tests/components/valve/test_trigger.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index fcd881581d4..8980ced5bbd 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -191,6 +191,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "todo", "update", "vacuum", + "valve", "water_heater", "window", } diff --git a/homeassistant/components/valve/icons.json b/homeassistant/components/valve/icons.json index bc01ba77175..c5bccd46b14 100644 --- a/homeassistant/components/valve/icons.json +++ b/homeassistant/components/valve/icons.json @@ -40,5 +40,13 @@ "toggle": { "service": "mdi:valve-open" } + }, + "triggers": { + "closed": { + "trigger": "mdi:valve-closed" + }, + "opened": { + "trigger": "mdi:valve-open" + } } } diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index 09bd02ba207..cd01e3142cf 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted valves to trigger on.", + "trigger_behavior_name": "Behavior" + }, "conditions": { "is_closed": { "description": "Tests if one or more valves are closed.", @@ -50,6 +54,13 @@ "all": "All", "any": "Any" } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } } }, "services": { @@ -80,5 +91,27 @@ "name": "Toggle valve" } }, - "title": "Valve" + "title": "Valve", + "triggers": { + "closed": { + "description": "Triggers after one or more valves close.", + "fields": { + "behavior": { + "description": "[%key:component::valve::common::trigger_behavior_description%]", + "name": "[%key:component::valve::common::trigger_behavior_name%]" + } + }, + "name": "Valve closed" + }, + "opened": { + "description": "Triggers after one or more valves open.", + "fields": { + "behavior": { + "description": "[%key:component::valve::common::trigger_behavior_description%]", + "name": "[%key:component::valve::common::trigger_behavior_name%]" + } + }, + "name": "Valve opened" + } + } } diff --git a/homeassistant/components/valve/trigger.py b/homeassistant/components/valve/trigger.py new file mode 100644 index 00000000000..8459accd4eb --- /dev/null +++ b/homeassistant/components/valve/trigger.py @@ -0,0 +1,24 @@ +"""Provides triggers for valves.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger + +from . import ATTR_IS_CLOSED, DOMAIN + +VALVE_DOMAIN_SPECS = {DOMAIN: DomainSpec(value_source=ATTR_IS_CLOSED)} + + +TRIGGERS: dict[str, type[Trigger]] = { + "closed": make_entity_transition_trigger( + VALVE_DOMAIN_SPECS, from_states={False}, to_states={True} + ), + "opened": make_entity_transition_trigger( + VALVE_DOMAIN_SPECS, from_states={True}, to_states={False} + ), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for valves.""" + return TRIGGERS diff --git a/homeassistant/components/valve/triggers.yaml b/homeassistant/components/valve/triggers.yaml new file mode 100644 index 00000000000..aaf09598d65 --- /dev/null +++ b/homeassistant/components/valve/triggers.yaml @@ -0,0 +1,18 @@ +.trigger_common: &trigger_common + target: + entity: + domain: valve + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +closed: *trigger_common +opened: *trigger_common diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 404051fd5fc..99dd07ac75f 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -482,8 +482,8 @@ class EntityTargetStateTriggerBase(EntityTriggerBase): class EntityTransitionTriggerBase(EntityTriggerBase): """Trigger for entity state changes between specific states.""" - _from_states: set[str] - _to_states: set[str] + _from_states: set[str | bool] + _to_states: set[str | bool] def is_valid_transition(self, from_state: State, to_state: State) -> bool: """Check if the origin state matches the expected ones.""" @@ -838,8 +838,8 @@ def make_entity_target_state_trigger( def make_entity_transition_trigger( domain_specs: Mapping[str, DomainSpec] | str, *, - from_states: set[str], - to_states: set[str], + from_states: set[str | bool], + to_states: set[str | bool], ) -> type[EntityTransitionTriggerBase]: """Create a trigger for entity state changes between specific states. diff --git a/tests/components/valve/test_trigger.py b/tests/components/valve/test_trigger.py new file mode 100644 index 00000000000..ae0a1f038e4 --- /dev/null +++ b/tests/components/valve/test_trigger.py @@ -0,0 +1,161 @@ +"""Test valve trigger.""" + +from typing import Any + +import pytest + +from homeassistant.components.valve import ATTR_IS_CLOSED, DOMAIN, ValveState +from homeassistant.core import HomeAssistant + +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_target_entities, + parametrize_trigger_states, + target_entities, +) + +TRIGGER_STATES = [ + *parametrize_trigger_states( + trigger="valve.closed", + target_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + (ValveState.OPENING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + ], + extra_invalid_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: None}), + (ValveState.OPEN, {}), + ], + ), + *parametrize_trigger_states( + trigger="valve.opened", + target_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + ], + extra_invalid_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: None}), + (ValveState.CLOSED, {}), + ], + ), +] + + +@pytest.fixture +async def target_valves(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple valve entities associated with different targets.""" + return await target_entities(hass, DOMAIN) + + +@pytest.mark.parametrize( + "trigger_key", + [ + "valve.closed", + "valve.opened", + ], +) +async def test_valve_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the valve 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(DOMAIN), +) +@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES) +async def test_valve_state_trigger_behavior_any( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any] | None, + states: list[TriggerStateDescription], +) -> None: + """Test that the valve state trigger fires when any valve state changes to a specific state.""" + await assert_trigger_behavior_any( + hass, + target_entities=target_valves, + 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(DOMAIN), +) +@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES) +async def test_valve_state_trigger_behavior_first( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + trigger_target_config: dict, + entities_in_target: int, + entity_id: str, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test that the valve state trigger fires when the first valve changes to a specific state.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_valves, + 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(DOMAIN), +) +@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES) +async def test_valve_state_trigger_behavior_last( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + trigger_target_config: dict, + entities_in_target: int, + entity_id: str, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test that the valve state trigger fires when the last valve changes to a specific state.""" + await assert_trigger_behavior_last( + hass, + target_entities=target_valves, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) From 46b1981b77040ee57d269d74c8eb9cdbfc961ffa Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:14:19 +0200 Subject: [PATCH 099/138] Bump aiontfy to 0.8.3 (#166770) --- homeassistant/components/ntfy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index b327c1e2b93..dda80fef257 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aiontfy"], "quality_scale": "platinum", - "requirements": ["aiontfy==0.8.1"] + "requirements": ["aiontfy==0.8.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d6c677709c..d88508f4320 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -339,7 +339,7 @@ aionanoleaf2==1.0.2 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.8.1 +aiontfy==0.8.3 # homeassistant.components.nut aionut==4.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ebaee0739d..777248e1a10 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -324,7 +324,7 @@ aionanoleaf2==1.0.2 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.8.1 +aiontfy==0.8.3 # homeassistant.components.nut aionut==4.3.4 From fa0ea041adc958932236f2b33455e6b468999afa Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 31 Mar 2026 15:53:15 +1000 Subject: [PATCH 100/138] Fix Tesla Fleet startup scopes after OAuth refresh (#166922) --- .../components/tesla_fleet/__init__.py | 14 +++++++--- tests/components/tesla_fleet/test_init.py | 26 ++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index f1acf192a32..5ea9ebc040f 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -16,7 +16,7 @@ from tesla_fleet_api.exceptions import ( from tesla_fleet_api.tesla import VehicleFleet from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -121,7 +121,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - ) raise ConfigEntryAuthFailed from e - access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + oauth_session = OAuth2Session(hass, entry, implementation) + try: + await oauth_session.async_ensure_token_valid() + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed from err + except OAuth2TokenRequestError as err: + raise ConfigEntryNotReady from err + + access_token = oauth_session.token[CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) token = jwt.decode(access_token, options={"verify_signature": False}) @@ -129,8 +137,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - region_code = token["ou_code"].lower() region = region_code if is_valid_region(region_code) else None - oauth_session = OAuth2Session(hass, entry, implementation) - async def _get_access_token() -> str: await oauth_session.async_ensure_token_valid() token: str = oauth_session.token[CONF_ACCESS_TOKEN] diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 1a2afe76837..47d58e1734f 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -19,7 +19,7 @@ from tesla_fleet_api.exceptions import ( VehicleOffline, ) -from homeassistant.components.tesla_fleet.const import DOMAIN +from homeassistant.components.tesla_fleet.const import DOMAIN, SCOPES from homeassistant.components.tesla_fleet.coordinator import ( ENERGY_HISTORY_INTERVAL, ENERGY_INTERVAL, @@ -136,6 +136,30 @@ async def test_oauth_refresh_error( assert normal_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_uses_scopes_from_refreshed_token( + hass: HomeAssistant, + noscope_config_entry: MockConfigEntry, +) -> None: + """Test setup uses scopes from the refreshed OAuth token.""" + refreshed_token = create_config_entry( + expires_at=3600, + scopes=SCOPES, + ).data[CONF_TOKEN] + + noscope_config_entry.data[CONF_TOKEN]["expires_at"] = 0 + + with patch( + "homeassistant.components.tesla_fleet.oauth.TeslaUserImplementation.async_refresh_token", + return_value=refreshed_token, + ) as mock_async_refresh_token: + await setup_platform(hass, noscope_config_entry) + + mock_async_refresh_token.assert_awaited_once() + assert noscope_config_entry.state is ConfigEntryState.LOADED + assert noscope_config_entry.runtime_data.scopes == SCOPES + assert noscope_config_entry.runtime_data.vehicles + + async def test_invalidate_access_token_updates_when_not_expired( hass: HomeAssistant, normal_config_entry: MockConfigEntry, From 39f2e89c4b80d768cd5571d2a2a8cb0e3d8c797f Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:05:37 +0200 Subject: [PATCH 101/138] Bump aiontfy to 0.8.4 (#166917) --- homeassistant/components/ntfy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index dda80fef257..f033f1e8369 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aiontfy"], "quality_scale": "platinum", - "requirements": ["aiontfy==0.8.3"] + "requirements": ["aiontfy==0.8.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index d88508f4320..f538c0c3893 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -339,7 +339,7 @@ aionanoleaf2==1.0.2 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.8.3 +aiontfy==0.8.4 # homeassistant.components.nut aionut==4.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 777248e1a10..7119424c860 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -324,7 +324,7 @@ aionanoleaf2==1.0.2 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.8.3 +aiontfy==0.8.4 # homeassistant.components.nut aionut==4.3.4 From 8ac66e888e71ada3fc6bbeb40c2b30639fc8ff61 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Mar 2026 07:37:18 +0000 Subject: [PATCH 102/138] Bump version to 2026.4.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c110ace32f7..cb559662052 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index c5c0b39b021..5d2f6ee5f63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0b5" +version = "2026.4.0b6" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 3472a2bfbf2160e70ab08cc5b4b90dc57a65c767 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 31 Mar 2026 10:10:41 +0200 Subject: [PATCH 103/138] Use async download for translations (#166940) --- script/translations/const.py | 2 +- script/translations/download.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/script/translations/const.py b/script/translations/const.py index 18aa27b3e74..ce1cde14f55 100644 --- a/script/translations/const.py +++ b/script/translations/const.py @@ -4,6 +4,6 @@ import pathlib CORE_PROJECT_ID = "130246255a974bd3b5e8a1.51616605" FRONTEND_PROJECT_ID = "3420425759f6d6d241f598.13594006" -CLI_2_DOCKER_IMAGE = "v2.6.14" +CLI_2_DOCKER_IMAGE = "v3.1.4" INTEGRATIONS_DIR = pathlib.Path("homeassistant/components") FRONTEND_DIR = pathlib.Path("../frontend") diff --git a/script/translations/download.py b/script/translations/download.py index 4ed2d8f045f..8caf7e3ec58 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -40,6 +40,7 @@ def run_download_docker() -> None: "file", "download", CORE_PROJECT_ID, + "--async", "--original-filenames=false", "--replace-breaks=false", "--filter-data", From 19166e793820f95879a316ed7ed53e56f65d98bb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Mar 2026 08:25:00 +0000 Subject: [PATCH 104/138] Bump version to 2026.4.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index cb559662052..f1a5b32e032 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index 5d2f6ee5f63..4ba845e8120 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0b6" +version = "2026.4.0b7" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From df6b2ba0cde8da8037e17b4bbab0f4bbba70923a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Mar 2026 13:01:20 +0200 Subject: [PATCH 105/138] Improve date action naming consistency (#166529) --- homeassistant/components/date/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/date/strings.json b/homeassistant/components/date/strings.json index fb4976f5399..a406772a8ab 100644 --- a/homeassistant/components/date/strings.json +++ b/homeassistant/components/date/strings.json @@ -6,7 +6,7 @@ }, "services": { "set_value": { - "description": "Sets the date.", + "description": "Sets the value of a date.", "fields": { "date": { "description": "The date to set.", From dcf6416ae9f0b2e7441d4b1ea72b51cc7cbc5c3d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Mar 2026 13:01:32 +0200 Subject: [PATCH 106/138] Improve datetime action naming consistency (#166530) --- homeassistant/components/datetime/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/datetime/strings.json b/homeassistant/components/datetime/strings.json index 8316bbaedb5..3fb944185f4 100644 --- a/homeassistant/components/datetime/strings.json +++ b/homeassistant/components/datetime/strings.json @@ -6,7 +6,7 @@ }, "services": { "set_value": { - "description": "Sets the date/time for a datetime entity.", + "description": "Sets the value of a date/time.", "fields": { "datetime": { "description": "The date/time to set. The time zone of the Home Assistant instance is assumed.", From a9e1bbd5ab4f28cc68d38c3a54553f2a97b080f8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Mar 2026 11:16:18 +0200 Subject: [PATCH 107/138] Improve time action naming consistency (#166532) --- homeassistant/components/time/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/time/strings.json b/homeassistant/components/time/strings.json index e22b3b325b8..463e6c8d1d1 100644 --- a/homeassistant/components/time/strings.json +++ b/homeassistant/components/time/strings.json @@ -6,14 +6,14 @@ }, "services": { "set_value": { - "description": "Sets the time.", + "description": "Sets the value of a time entity.", "fields": { "time": { "description": "The time to set.", "name": "Time" } }, - "name": "Set Time" + "name": "Set time" } }, "title": "Time" From fc6efac559eb101b960714b22df30b5a9df3cb8a Mon Sep 17 00:00:00 2001 From: Andreas Jakl Date: Tue, 31 Mar 2026 12:55:04 +0200 Subject: [PATCH 108/138] Prevent invalid phase count state in nrgkick (#166575) --- homeassistant/components/nrgkick/number.py | 54 +++++++--- tests/components/nrgkick/test_number.py | 112 ++++++++++++++++++++- 2 files changed, 149 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/nrgkick/number.py b/homeassistant/components/nrgkick/number.py index 3261650b824..aff9ccfc494 100644 --- a/homeassistant/components/nrgkick/number.py +++ b/homeassistant/components/nrgkick/number.py @@ -87,19 +87,18 @@ NUMBERS: tuple[NRGkickNumberEntityDescription, ...] = ( int(value) ), ), - NRGkickNumberEntityDescription( - key="phase_count", - translation_key="phase_count", - native_min_value=1, - native_max_value=3, - native_step=1, - mode=NumberMode.SLIDER, - value_fn=lambda data: data.control.get(CONTROL_KEY_PHASE_COUNT), - set_value_fn=lambda coordinator, value: coordinator.api.set_phase_count( - int(value) - ), - max_value_fn=_get_phase_count_max, - ), +) + +PHASE_COUNT_DESCRIPTION = NRGkickNumberEntityDescription( + key="phase_count", + translation_key="phase_count", + native_min_value=1, + native_max_value=3, + native_step=1, + mode=NumberMode.SLIDER, + value_fn=lambda data: data.control.get(CONTROL_KEY_PHASE_COUNT), + set_value_fn=lambda coordinator, value: coordinator.api.set_phase_count(int(value)), + max_value_fn=_get_phase_count_max, ) @@ -111,9 +110,11 @@ async def async_setup_entry( """Set up NRGkick number entities based on a config entry.""" coordinator = entry.runtime_data - async_add_entities( + entities: list[NRGkickNumber] = [ NRGkickNumber(coordinator, description) for description in NUMBERS - ) + ] + entities.append(NRGkickPhaseCountNumber(coordinator, PHASE_COUNT_DESCRIPTION)) + async_add_entities(entities) class NRGkickNumber(NRGkickEntity, NumberEntity): @@ -153,3 +154,26 @@ class NRGkickNumber(NRGkickEntity, NumberEntity): await self._async_call_api( self.entity_description.set_value_fn(self.coordinator, value) ) + + +class NRGkickPhaseCountNumber(NRGkickNumber): + """Phase count number entity with optimistic state. + + The device briefly reports 0 phases while switching. This subclass + caches the last valid value to avoid exposing the transient state. + """ + + _last_phase_count: float | None = None + + @property + def native_value(self) -> float | None: + """Return the current value, filtering transient zeros.""" + value = super().native_value + if value is not None and value != 0: + self._last_phase_count = value + return self._last_phase_count + + async def async_set_native_value(self, value: float) -> None: + """Set phase count with optimistic update.""" + self._last_phase_count = int(value) + await super().async_set_native_value(value) diff --git a/tests/components/nrgkick/test_number.py b/tests/components/nrgkick/test_number.py index 601f1716d8a..b348b774b9c 100644 --- a/tests/components/nrgkick/test_number.py +++ b/tests/components/nrgkick/test_number.py @@ -2,8 +2,10 @@ from __future__ import annotations +from datetime import timedelta from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from nrgkick_api import NRGkickCommandRejectedError from nrgkick_api.const import ( CONTROL_KEY_CURRENT_SET, @@ -13,6 +15,7 @@ from nrgkick_api.const import ( import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.nrgkick.const import DEFAULT_SCAN_INTERVAL from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -25,7 +28,9 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL) pytestmark = pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -114,7 +119,7 @@ async def test_set_phase_count( assert (state := hass.states.get(entity_id)) assert state.state == "3" - # Set to 1 phase + # Set phase count to 1 control_data = mock_nrgkick_api.get_control.return_value.copy() control_data[CONTROL_KEY_PHASE_COUNT] = 1 mock_nrgkick_api.get_control.return_value = control_data @@ -130,6 +135,109 @@ async def test_set_phase_count( mock_nrgkick_api.set_phase_count.assert_awaited_once_with(1) +async def test_phase_count_filters_transient_zero_on_poll( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nrgkick_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a transient phase count of 0 from a poll is filtered. + + During a phase-count switch the device briefly reports 0 phases. + A coordinator refresh must not expose the transient value. + """ + await setup_integration(hass, mock_config_entry, platforms=[Platform.NUMBER]) + + entity_id = "number.nrgkick_test_phase_count" + + assert (state := hass.states.get(entity_id)) + assert state.state == "3" + + # One refresh happened during setup. + assert mock_nrgkick_api.get_control.call_count == 1 + + # Device briefly reports 0 during a phase switch. + control_data = mock_nrgkick_api.get_control.return_value.copy() + control_data[CONTROL_KEY_PHASE_COUNT] = 0 + mock_nrgkick_api.get_control.return_value = control_data + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify the coordinator actually polled the device. + assert mock_nrgkick_api.get_control.call_count == 2 + + # The transient 0 must not surface; state stays at the previous value. + assert (state := hass.states.get(entity_id)) + assert state.state == "3" + + # Once the device settles it reports the real phase count. + control_data = mock_nrgkick_api.get_control.return_value.copy() + control_data[CONTROL_KEY_PHASE_COUNT] = 1 + mock_nrgkick_api.get_control.return_value = control_data + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify the coordinator polled again. + assert mock_nrgkick_api.get_control.call_count == 3 + + assert (state := hass.states.get(entity_id)) + assert state.state == "1" + + +async def test_phase_count_filters_transient_zero_on_service_call( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nrgkick_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a service call keeps the cached value when refreshing returns 0. + + When the user sets a new phase count, the immediate refresh triggered + by the service call may still see 0. The entity should keep the + requested value instead. + """ + await setup_integration(hass, mock_config_entry, platforms=[Platform.NUMBER]) + + entity_id = "number.nrgkick_test_phase_count" + + assert (state := hass.states.get(entity_id)) + assert state.state == "3" + + # The refresh triggered by the service call will see 0. + control_data = mock_nrgkick_api.get_control.return_value.copy() + control_data[CONTROL_KEY_PHASE_COUNT] = 0 + mock_nrgkick_api.get_control.return_value = control_data + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 1}, + blocking=True, + ) + mock_nrgkick_api.set_phase_count.assert_awaited_once_with(1) + + # State must not show 0; the entity keeps the cached value. + assert (state := hass.states.get(entity_id)) + assert state.state == "1" + + # Once the device settles it reports the real phase count again. + control_data = mock_nrgkick_api.get_control.return_value.copy() + control_data[CONTROL_KEY_PHASE_COUNT] = 1 + mock_nrgkick_api.get_control.return_value = control_data + prior_call_count = mock_nrgkick_api.get_control.call_count + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify that a periodic refresh actually occurred. + assert mock_nrgkick_api.get_control.call_count > prior_call_count + + assert (state := hass.states.get(entity_id)) + assert state.state == "1" + + async def test_number_command_rejected_by_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 92375078c0e8a54bf28ae4a7aa0f78ae89f2b07b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Mar 2026 10:59:23 +0200 Subject: [PATCH 109/138] Make field description optional for non config flows (#166892) --- script/hassfest/translations.py | 8 ++++---- tests/hassfest/test_translations.py | 10 +++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 5e55b9a1ca8..d12bfb926e3 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -402,7 +402,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: cv.schema_with_slug_keys( { vol.Required("name"): str, - vol.Required( + vol.Optional( "description" ): translation_value_validator, }, @@ -508,7 +508,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("fields"): cv.schema_with_slug_keys( { vol.Required("name"): str, - vol.Required("description"): translation_value_validator, + vol.Optional("description"): translation_value_validator, vol.Optional("example"): translation_value_validator, }, slug_validator=translation_key_validator, @@ -530,7 +530,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("fields"): cv.schema_with_slug_keys( { vol.Required("name"): str, - vol.Required("description"): translation_value_validator, + vol.Optional("description"): translation_value_validator, vol.Optional("example"): translation_value_validator, }, slug_validator=translation_key_validator, @@ -545,7 +545,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("fields"): cv.schema_with_slug_keys( { vol.Required("name"): str, - vol.Required("description"): translation_value_validator, + vol.Optional("description"): translation_value_validator, vol.Optional("example"): translation_value_validator, }, slug_validator=translation_key_validator, diff --git a/tests/hassfest/test_translations.py b/tests/hassfest/test_translations.py index fdd903ab806..b0d79c1e7b8 100644 --- a/tests/hassfest/test_translations.py +++ b/tests/hassfest/test_translations.py @@ -166,6 +166,9 @@ SAMPLE_STRINGS = { "name": "Field one", "description": "Description of field one", }, + "field_two": { + "name": "Field two", + }, }, }, "field_old": { @@ -346,7 +349,6 @@ SAMPLE_STRINGS = { }, "target": { "name": "Target", - "description": "The target device", }, }, "sections": { @@ -371,6 +373,9 @@ SAMPLE_STRINGS = { "description": "The entity to check", "example": "light.living_room", }, + "some_option": { + "name": "Some option", + }, }, }, }, @@ -384,6 +389,9 @@ SAMPLE_STRINGS = { "description": "The entity to monitor", "example": "light.living_room", }, + "some_option": { + "name": "Some option", + }, }, }, }, From d4d639dfa2f3986cc1c324f51a7d08e22b5778b8 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:49:38 +0200 Subject: [PATCH 110/138] Register trigger platform upon use (#166911) --- homeassistant/helpers/trigger.py | 29 +++++++++++++------- tests/helpers/test_trigger.py | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 99dd07ac75f..cde2e3adc67 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -202,22 +202,28 @@ async def _register_trigger_platform( from homeassistant.components import automation # noqa: PLC0415 new_triggers: set[str] = set() + triggers = hass.data[TRIGGERS] if hasattr(platform, "async_get_triggers"): - for trigger_key in await platform.async_get_triggers(hass): + all_triggers = await platform.async_get_triggers(hass) + for trigger_key in all_triggers: trigger_key = get_absolute_description_key(integration_domain, trigger_key) - hass.data[TRIGGERS][trigger_key] = integration_domain - new_triggers.add(trigger_key) + if trigger_key not in triggers: + triggers[trigger_key] = integration_domain + new_triggers.add(trigger_key) if not new_triggers: - _LOGGER.debug( - "Integration %s returned no triggers in async_get_triggers", - integration_domain, - ) + if not all_triggers: + _LOGGER.debug( + "Integration %s returned no triggers in async_get_triggers", + integration_domain, + ) return elif hasattr(platform, "async_validate_trigger_config") or hasattr( platform, "TRIGGER_SCHEMA" ): - hass.data[TRIGGERS][integration_domain] = integration_domain + if integration_domain in triggers: + return + triggers[integration_domain] = integration_domain new_triggers.add(integration_domain) else: _LOGGER.debug( @@ -1184,12 +1190,17 @@ async def _async_get_trigger_platform( except IntegrationNotFound: raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") from None try: - return platform, await integration.async_get_platform("trigger") + platform_module = await integration.async_get_platform("trigger") except ImportError: raise vol.Invalid( f"Integration '{platform}' does not provide trigger support" ) from None + # Ensure triggers are registered so descriptions can be loaded + await _register_trigger_platform(hass, platform, platform_module) + + return platform, platform_module + async def async_validate_trigger_config( hass: HomeAssistant, trigger_config: list[ConfigType] diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 8ba53241771..e9122a20de3 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -42,6 +42,7 @@ from homeassistant.helpers.automation import ( ) from homeassistant.helpers.trigger import ( DATA_PLUGGABLE_ACTIONS, + TRIGGERS, EntityNumericalStateChangedTriggerWithUnitBase, EntityNumericalStateCrossedThresholdTriggerWithUnitBase, EntityTriggerBase, @@ -669,6 +670,51 @@ async def test_platform_backwards_compatibility_for_new_style_configs( assert result == config_old_style +async def test_get_trigger_platform_registers_triggers( + hass: HomeAssistant, +) -> None: + """Test _async_get_trigger_platform registers triggers and notifies subscribers.""" + + class MockTrigger(Trigger): + """Mock trigger.""" + + async def async_attach_runner( + self, run_action: TriggerActionRunner + ) -> CALLBACK_TYPE: + return lambda: None + + async def async_get_triggers( + hass: HomeAssistant, + ) -> dict[str, type[Trigger]]: + return {"trig_a": MockTrigger, "trig_b": MockTrigger} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + subscriber_events: list[set[str]] = [] + + async def subscriber(new_triggers: set[str]) -> None: + subscriber_events.append(new_triggers) + + trigger.async_subscribe_platform_events(hass, subscriber) + + assert "test.trig_a" not in hass.data[TRIGGERS] + assert "test.trig_b" not in hass.data[TRIGGERS] + + # First call registers all triggers from the platform and notifies subscribers + await _async_get_trigger_platform(hass, "test.trig_a") + + assert hass.data[TRIGGERS]["test.trig_a"] == "test" + assert hass.data[TRIGGERS]["test.trig_b"] == "test" + assert len(subscriber_events) == 1 + assert subscriber_events[0] == {"test.trig_a", "test.trig_b"} + + # Subsequent calls are idempotent — no re-registration or re-notification + await _async_get_trigger_platform(hass, "test.trig_a") + await _async_get_trigger_platform(hass, "test.trig_b") + assert len(subscriber_events) == 1 + + @pytest.mark.parametrize( "sun_trigger_descriptions", [ From 4d168023a2490dd9d34fbaa01ddfe8582c4889dd Mon Sep 17 00:00:00 2001 From: Branden Cash <203336+ammmze@users.noreply.github.com> Date: Tue, 31 Mar 2026 06:48:48 -0700 Subject: [PATCH 111/138] Bump srpenergy to 1.3.8 (#166926) --- homeassistant/components/srp_energy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/srp_energy/manifest.json b/homeassistant/components/srp_energy/manifest.json index 27deb87b0ca..ccbe73a97fd 100644 --- a/homeassistant/components/srp_energy/manifest.json +++ b/homeassistant/components/srp_energy/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["srpenergy"], - "requirements": ["srpenergy==1.3.6"] + "requirements": ["srpenergy==1.3.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index f538c0c3893..1250d8942f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3011,7 +3011,7 @@ spotifyaio==2.0.2 sqlparse==0.5.5 # homeassistant.components.srp_energy -srpenergy==1.3.6 +srpenergy==1.3.8 # homeassistant.components.starline starline==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7119424c860..467420f92cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2553,7 +2553,7 @@ spotifyaio==2.0.2 sqlparse==0.5.5 # homeassistant.components.srp_energy -srpenergy==1.3.6 +srpenergy==1.3.8 # homeassistant.components.starline starline==0.1.5 From d4320922968edd535da476ef3c9d8e8edefe2153 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:35:17 +0200 Subject: [PATCH 112/138] Fix StopIteration error in ista EcoTrend coordinator (#166929) --- homeassistant/components/ista_ecotrend/__init__.py | 1 - homeassistant/components/ista_ecotrend/config_flow.py | 2 -- homeassistant/components/ista_ecotrend/coordinator.py | 8 +++----- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ista_ecotrend/__init__.py b/homeassistant/components/ista_ecotrend/__init__.py index e39850d6c51..747e33835b1 100644 --- a/homeassistant/components/ista_ecotrend/__init__.py +++ b/homeassistant/components/ista_ecotrend/__init__.py @@ -23,7 +23,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool ista = PyEcotrendIsta( entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], - _LOGGER, ) coordinator = IstaCoordinator(hass, entry, ista) diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py index 3eb7c4720b2..e24441c9f4e 100644 --- a/homeassistant/components/ista_ecotrend/config_flow.py +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -51,7 +51,6 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): ista = PyEcotrendIsta( user_input[CONF_EMAIL], user_input[CONF_PASSWORD], - _LOGGER, ) try: await self.hass.async_add_executor_job(ista.login) @@ -102,7 +101,6 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): ista = PyEcotrendIsta( user_input[CONF_EMAIL], user_input[CONF_PASSWORD], - _LOGGER, ) def get_consumption_units() -> set[str]: diff --git a/homeassistant/components/ista_ecotrend/coordinator.py b/homeassistant/components/ista_ecotrend/coordinator.py index 13167b9d06c..75591b09728 100644 --- a/homeassistant/components/ista_ecotrend/coordinator.py +++ b/homeassistant/components/ista_ecotrend/coordinator.py @@ -94,10 +94,8 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): result = self.ista.get_consumption_unit_details() return { - consumption_unit: next( - details - for details in result["consumptionUnits"] - if details["id"] == consumption_unit - ) + consumption_unit: details for consumption_unit in self.ista.get_uuids() + for details in result["consumptionUnits"] + if details["id"] == consumption_unit } From 8be6f441ddc76205dad677b39668b5c5846f65bc Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:53:36 +0200 Subject: [PATCH 113/138] Register condition platform upon use (#166939) --- homeassistant/helpers/condition.py | 25 +++++++++----- tests/helpers/test_condition.py | 53 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5cf8df5d36c..810b8f40b73 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -230,19 +230,23 @@ async def _register_condition_platform( from homeassistant.components import automation # noqa: PLC0415 new_conditions: set[str] = set() + conditions = hass.data[CONDITIONS] if hasattr(platform, "async_get_conditions"): - for condition_key in await platform.async_get_conditions(hass): + all_conditions = await platform.async_get_conditions(hass) + for condition_key in all_conditions: condition_key = get_absolute_description_key( integration_domain, condition_key ) - hass.data[CONDITIONS][condition_key] = integration_domain - new_conditions.add(condition_key) + if condition_key not in conditions: + conditions[condition_key] = integration_domain + new_conditions.add(condition_key) if not new_conditions: - _LOGGER.debug( - "Integration %s returned no conditions in async_get_conditions", - integration_domain, - ) + if not all_conditions: + _LOGGER.debug( + "Integration %s returned no conditions in async_get_conditions", + integration_domain, + ) return else: _LOGGER.debug( @@ -821,12 +825,17 @@ async def _async_get_condition_platform( f'Invalid condition "{condition_key}" specified' ) from None try: - return platform, await integration.async_get_platform("condition") + platform_module = await integration.async_get_platform("condition") except ImportError: raise HomeAssistantError( f"Integration '{platform}' does not provide condition support" ) from None + # Ensure conditions are registered so descriptions can be loaded + await _register_condition_platform(hass, platform, platform_module) + + return platform, platform_module + async def _async_get_checker(condition: Condition) -> ConditionCheckerType: new_checker = await condition.async_get_checker() diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index e21a3d048d0..ab5fe80825c 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -48,9 +48,11 @@ from homeassistant.helpers.condition import ( ATTR_BEHAVIOR, BEHAVIOR_ALL, BEHAVIOR_ANY, + CONDITIONS, Condition, ConditionChecker, EntityNumericalConditionWithUnitBase, + _async_get_condition_platform, async_validate_condition_config, make_entity_numerical_condition, make_entity_numerical_condition_with_unit, @@ -2276,6 +2278,57 @@ async def test_platform_backwards_compatibility_for_new_style_configs( assert result == config_old_style +async def test_get_condition_platform_registers_conditions( + hass: HomeAssistant, +) -> None: + """Test _async_get_condition_platform registers conditions and notifies subscribers.""" + + class MockCondition(Condition): + """Mock condition.""" + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + return config + + async def async_get_checker(self) -> ConditionChecker: + return lambda **kwargs: True + + async def async_get_conditions( + hass: HomeAssistant, + ) -> dict[str, type[Condition]]: + return {"cond_a": MockCondition, "cond_b": MockCondition} + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=async_get_conditions) + ) + + subscriber_events: list[set[str]] = [] + + async def subscriber(new_conditions: set[str]) -> None: + subscriber_events.append(new_conditions) + + condition.async_subscribe_platform_events(hass, subscriber) + + assert "test.cond_a" not in hass.data[CONDITIONS] + assert "test.cond_b" not in hass.data[CONDITIONS] + + # First call registers all conditions from the platform and notifies subscribers + await _async_get_condition_platform(hass, "test.cond_a") + + assert hass.data[CONDITIONS]["test.cond_a"] == "test" + assert hass.data[CONDITIONS]["test.cond_b"] == "test" + assert len(subscriber_events) == 1 + assert subscriber_events[0] == {"test.cond_a", "test.cond_b"} + + # Subsequent calls are idempotent — no re-registration or re-notification + await _async_get_condition_platform(hass, "test.cond_a") + await _async_get_condition_platform(hass, "test.cond_b") + assert len(subscriber_events) == 1 + + @pytest.mark.parametrize("enabled_value", [True, "{{ 1 == 1 }}"]) async def test_enabled_condition( hass: HomeAssistant, enabled_value: bool | str From 11d9f236b9acf7cd1c7f2c2ce91a075f92496466 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 31 Mar 2026 15:26:21 +0200 Subject: [PATCH 114/138] Fix "Shutdown" grammar in Roborock strings (#166948) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/roborock/strings.json | 2 +- .../components/roborock/snapshots/test_button.ambr | 14 +++++++------- tests/components/roborock/test_button.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 66bff33c510..e3ba066f9ba 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -103,7 +103,7 @@ "name": "Reset side brush consumable" }, "shutdown": { - "name": "Shutdown" + "name": "Shut down" }, "start": { "name": "Start" diff --git a/tests/components/roborock/snapshots/test_button.ambr b/tests/components/roborock/snapshots/test_button.ambr index 9b0fb4addfd..272e822965a 100644 --- a/tests/components/roborock/snapshots/test_button.ambr +++ b/tests/components/roborock/snapshots/test_button.ambr @@ -699,7 +699,7 @@ 'state': 'unknown', }) # --- -# name: test_buttons[button.zeo_one_shutdown-entry] +# name: test_buttons[button.zeo_one_shut_down-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -713,7 +713,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': None, - 'entity_id': 'button.zeo_one_shutdown', + 'entity_id': 'button.zeo_one_shut_down', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -721,12 +721,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Shutdown', + 'object_id_base': 'Shut down', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Shutdown', + 'original_name': 'Shut down', 'platform': 'roborock', 'previous_unique_id': None, 'suggested_object_id': None, @@ -736,13 +736,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[button.zeo_one_shutdown-state] +# name: test_buttons[button.zeo_one_shut_down-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Zeo One Shutdown', + 'friendly_name': 'Zeo One Shut down', }), 'context': , - 'entity_id': 'button.zeo_one_shutdown', + 'entity_id': 'button.zeo_one_shut_down', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 515d61c499c..fcbbff13fb0 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -199,7 +199,7 @@ async def test_press_routine_button_failure( [ ("button.zeo_one_start", "START"), ("button.zeo_one_pause", "PAUSE"), - ("button.zeo_one_shutdown", "SHUTDOWN"), + ("button.zeo_one_shut_down", "SHUTDOWN"), ], ) @pytest.mark.freeze_time("2023-10-30 08:50:00") From 871d9ee0b4f6a358343515506afac47e46ebfb09 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Mar 2026 17:39:42 +0200 Subject: [PATCH 115/138] Remove calendar and todo from unconditionally loaded integrations (#166951) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- homeassistant/bootstrap.py | 4 +++- tests/snapshots/test_bootstrap.ambr | 4 ---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index ce411280772..226aec8f130 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -238,7 +238,9 @@ DEFAULT_INTEGRATIONS = { "timer", # # Base platforms: - *BASE_PLATFORMS, + # Note: Calendar and todo are not included to prevent them from registering + # their frontend panels when there are no calendar or todo integrations. + *(BASE_PLATFORMS - {"calendar", "todo"}), # # Integrations providing triggers and conditions for base platforms: "air_quality", diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index 67d20710aa4..8e21e77d4f8 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -19,7 +19,6 @@ 'blueprint', 'brands', 'button', - 'calendar', 'camera', 'climate', 'config', @@ -94,7 +93,6 @@ 'text', 'time', 'timer', - 'todo', 'trace', 'tts', 'update', @@ -129,7 +127,6 @@ 'blueprint', 'brands', 'button', - 'calendar', 'camera', 'climate', 'config', @@ -203,7 +200,6 @@ 'text', 'time', 'timer', - 'todo', 'trace', 'tts', 'update', From ce8519c1b1f064ab0e00e18b342803d3124994f6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Mar 2026 13:29:05 +0200 Subject: [PATCH 116/138] Update hassfest conditions, services and triggers plugins to not require field descriptions (#166954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- script/hassfest/conditions.py | 18 ++---------------- script/hassfest/services.py | 15 ++------------- script/hassfest/triggers.py | 18 ++---------------- tests/hassfest/test_conditions.py | 8 ++++++-- tests/hassfest/test_triggers.py | 6 ++++-- 5 files changed, 16 insertions(+), 49 deletions(-) diff --git a/script/hassfest/conditions.py b/script/hassfest/conditions.py index 22449cfd636..6fef91309fd 100644 --- a/script/hassfest/conditions.py +++ b/script/hassfest/conditions.py @@ -231,8 +231,8 @@ def validate_conditions(config: Config, integration: Integration) -> None: # no f"Condition {condition_name} has no description {error_msg_suffix}", ) - # The same check is done for the description in each of the fields of the - # condition schema. + # The same check is done for each of the fields of the condition schema, + # except that we don't enforce that fields have a description. for field_name, field_schema in condition_schema.get("fields", {}).items(): if "fields" in field_schema: # This is a section @@ -249,20 +249,6 @@ def validate_conditions(config: Config, integration: Integration) -> None: # no ), ) - if "description" not in field_schema and integration.core: - try: - strings["conditions"][condition_name]["fields"][field_name][ - "description" - ] - except KeyError: - integration.add_error( - "conditions", - ( - f"Condition {condition_name} has a field {field_name} with no " - f"description {error_msg_suffix}" - ), - ) - if "selector" in field_schema: with contextlib.suppress(KeyError): translation_key = field_schema["selector"]["select"][ diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 723a9ec9278..5e2d3cae587 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -303,8 +303,8 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa integration, service_name, strings, service_schema ) - # The same check is done for the description in each of the fields of the - # service schema. + # The same check is done for each field in the service schema, + # except that we don't require fields to have a description. for field_name, field_schema in service_schema.get("fields", {}).items(): if "fields" in field_schema: # This is a section @@ -318,17 +318,6 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa f"Service {service_name} has a field {field_name} with no name {error_msg_suffix}", ) - if "description" not in field_schema and integration.core: - try: - strings["services"][service_name]["fields"][field_name][ - "description" - ] - except KeyError: - integration.add_error( - "services", - f"Service {service_name} has a field {field_name} with no description {error_msg_suffix}", - ) - if "selector" in field_schema: with contextlib.suppress(KeyError): translation_key = field_schema["selector"]["select"][ diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py index 86e4a475475..87bbb4d8f57 100644 --- a/script/hassfest/triggers.py +++ b/script/hassfest/triggers.py @@ -245,8 +245,8 @@ def validate_triggers(config: Config, integration: Integration) -> None: # noqa f"Trigger {trigger_name} has no description {error_msg_suffix}", ) - # The same check is done for the description in each of the fields of the - # trigger schema. + # The same check is done for each of the fields of the trigger schema, + # except that we don't enforce that fields have a description. for field_name, field_schema in trigger_schema.get("fields", {}).items(): if "fields" in field_schema: # This is a section @@ -263,20 +263,6 @@ def validate_triggers(config: Config, integration: Integration) -> None: # noqa ), ) - if "description" not in field_schema and integration.core: - try: - strings["triggers"][trigger_name]["fields"][field_name][ - "description" - ] - except KeyError: - integration.add_error( - "triggers", - ( - f"Trigger {trigger_name} has a field {field_name} with no " - f"description {error_msg_suffix}" - ), - ) - if "selector" in field_schema: with contextlib.suppress(KeyError): translation_key = field_schema["selector"]["select"][ diff --git a/tests/hassfest/test_conditions.py b/tests/hassfest/test_conditions.py index 12c3682e92d..8cfdf4a0270 100644 --- a/tests/hassfest/test_conditions.py +++ b/tests/hassfest/test_conditions.py @@ -35,6 +35,9 @@ CONDITION_DESCRIPTIONS = { after_offset: selector: time: null + after_offset_no_description: + selector: + time: null """, CONDITION_ICONS_FILENAME: {"conditions": {"_": {"condition": "mdi:flash"}}}, CONDITION_STRINGS_FILENAME: { @@ -48,6 +51,9 @@ CONDITION_DESCRIPTIONS = { "name": "Offset", "description": "The offset.", }, + "after_offset_no_description": { + "name": "Offset", + }, }, } } @@ -105,10 +111,8 @@ CONDITION_DESCRIPTIONS = { "has no name", "has no description", "field after with no name", - "field after with no description", "field after with a selector with a translation key", "field after_offset with no name", - "field after_offset with no description", ], }, } diff --git a/tests/hassfest/test_triggers.py b/tests/hassfest/test_triggers.py index 0bd28fd4e80..e3f43740ed1 100644 --- a/tests/hassfest/test_triggers.py +++ b/tests/hassfest/test_triggers.py @@ -32,6 +32,9 @@ TRIGGER_DESCRIPTIONS = { offset: selector: time: null + offset_no_description: + selector: + time: null """, TRIGGER_ICONS_FILENAME: {"triggers": {"_": {"trigger": "mdi:flash"}}}, TRIGGER_STRINGS_FILENAME: { @@ -42,6 +45,7 @@ TRIGGER_DESCRIPTIONS = { "fields": { "event": {"name": "Event", "description": "The event."}, "offset": {"name": "Offset", "description": "The offset."}, + "offset_no_description": {"name": "Offset"}, }, } } @@ -99,10 +103,8 @@ TRIGGER_DESCRIPTIONS = { "has no name", "has no description", "field event with no name", - "field event with no description", "field event with a selector with a translation key", "field offset with no name", - "field offset with no description", ], }, } From 22a6968a08455a08185be645380bc6e4f211ca16 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 15:39:10 +0100 Subject: [PATCH 117/138] Add timer conditions (#166641) Co-authored-by: Martin Hjelmare --- .../components/automation/__init__.py | 1 + homeassistant/components/timer/condition.py | 17 +++ .../components/timer/conditions.yaml | 18 +++ homeassistant/components/timer/icons.json | 11 ++ homeassistant/components/timer/strings.json | 44 ++++++ tests/components/timer/test_condition.py | 136 ++++++++++++++++++ 6 files changed, 227 insertions(+) create mode 100644 homeassistant/components/timer/condition.py create mode 100644 homeassistant/components/timer/conditions.yaml create mode 100644 tests/components/timer/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 8980ced5bbd..608a13e6ce9 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -147,6 +147,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "switch", "temperature", "text", + "timer", "vacuum", "valve", "water_heater", diff --git a/homeassistant/components/timer/condition.py b/homeassistant/components/timer/condition.py new file mode 100644 index 00000000000..130114ca5d0 --- /dev/null +++ b/homeassistant/components/timer/condition.py @@ -0,0 +1,17 @@ +"""Provides conditions for timers.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +from . import DOMAIN, STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED + +CONDITIONS: dict[str, type[Condition]] = { + "is_active": make_entity_state_condition(DOMAIN, STATUS_ACTIVE), + "is_paused": make_entity_state_condition(DOMAIN, STATUS_PAUSED), + "is_idle": make_entity_state_condition(DOMAIN, STATUS_IDLE), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the timer conditions.""" + return CONDITIONS diff --git a/homeassistant/components/timer/conditions.yaml b/homeassistant/components/timer/conditions.yaml new file mode 100644 index 00000000000..a94cf600933 --- /dev/null +++ b/homeassistant/components/timer/conditions.yaml @@ -0,0 +1,18 @@ +.condition_common: &condition_common + target: + entity: + - domain: timer + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_active: *condition_common +is_paused: *condition_common +is_idle: *condition_common diff --git a/homeassistant/components/timer/icons.json b/homeassistant/components/timer/icons.json index d2a7160750b..fcc398870aa 100644 --- a/homeassistant/components/timer/icons.json +++ b/homeassistant/components/timer/icons.json @@ -1,4 +1,15 @@ { + "conditions": { + "is_active": { + "condition": "mdi:timer" + }, + "is_idle": { + "condition": "mdi:timer-off" + }, + "is_paused": { + "condition": "mdi:timer-pause" + } + }, "services": { "cancel": { "service": "mdi:cancel" diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index de8518212d5..b1373b4764e 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,4 +1,40 @@ { + "common": { + "condition_behavior_description": "How the state should match on the targeted timers.", + "condition_behavior_name": "Behavior" + }, + "conditions": { + "is_active": { + "description": "Tests if one or more timers are active.", + "fields": { + "behavior": { + "description": "[%key:component::timer::common::condition_behavior_description%]", + "name": "[%key:component::timer::common::condition_behavior_name%]" + } + }, + "name": "Timer is active" + }, + "is_idle": { + "description": "Tests if one or more timers are idle.", + "fields": { + "behavior": { + "description": "[%key:component::timer::common::condition_behavior_description%]", + "name": "[%key:component::timer::common::condition_behavior_name%]" + } + }, + "name": "Timer is idle" + }, + "is_paused": { + "description": "Tests if one or more timers are paused.", + "fields": { + "behavior": { + "description": "[%key:component::timer::common::condition_behavior_description%]", + "name": "[%key:component::timer::common::condition_behavior_name%]" + } + }, + "name": "Timer is paused" + } + }, "entity_component": { "_": { "name": "Timer", @@ -30,6 +66,14 @@ } } }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + } + }, "services": { "cancel": { "description": "Resets a timer's duration to the last known initial value without firing the timer finished event.", diff --git a/tests/components/timer/test_condition.py b/tests/components/timer/test_condition.py new file mode 100644 index 00000000000..3a60edca4c0 --- /dev/null +++ b/tests/components/timer/test_condition.py @@ -0,0 +1,136 @@ +"""Test timer conditions.""" + +from typing import Any + +import pytest + +from homeassistant.components.timer import STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_timers(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple timer entities associated with different targets.""" + return await target_entities(hass, "timer") + + +@pytest.mark.parametrize( + "condition", + [ + "timer.is_active", + "timer.is_paused", + "timer.is_idle", + ], +) +async def test_timer_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the timer conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("timer"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="timer.is_active", + target_states=[STATUS_ACTIVE], + other_states=[STATUS_IDLE, STATUS_PAUSED], + ), + *parametrize_condition_states_any( + condition="timer.is_paused", + target_states=[STATUS_PAUSED], + other_states=[STATUS_IDLE, STATUS_ACTIVE], + ), + *parametrize_condition_states_any( + condition="timer.is_idle", + target_states=[STATUS_IDLE], + other_states=[STATUS_ACTIVE, STATUS_PAUSED], + ), + ], +) +async def test_timer_condition_behavior_any( + hass: HomeAssistant, + target_timers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test timer condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_timers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("timer"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="timer.is_active", + target_states=[STATUS_ACTIVE], + other_states=[STATUS_IDLE, STATUS_PAUSED], + ), + *parametrize_condition_states_all( + condition="timer.is_paused", + target_states=[STATUS_PAUSED], + other_states=[STATUS_IDLE, STATUS_ACTIVE], + ), + *parametrize_condition_states_all( + condition="timer.is_idle", + target_states=[STATUS_IDLE], + other_states=[STATUS_ACTIVE, STATUS_PAUSED], + ), + ], +) +async def test_timer_condition_behavior_all( + hass: HomeAssistant, + target_timers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test timer condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_timers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) From 96b7210bca3396f764fe85ffc6afd212c3339735 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 14:15:41 +0100 Subject: [PATCH 118/138] Add calendar conditions (#166643) --- .../components/automation/__init__.py | 1 + .../components/calendar/condition.py | 16 +++ .../components/calendar/conditions.yaml | 14 +++ homeassistant/components/calendar/icons.json | 5 + .../components/calendar/strings.json | 22 ++++ tests/components/calendar/test_condition.py | 114 ++++++++++++++++++ 6 files changed, 172 insertions(+) create mode 100644 homeassistant/components/calendar/condition.py create mode 100644 homeassistant/components/calendar/conditions.yaml create mode 100644 tests/components/calendar/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 608a13e6ce9..7fa8c5afb6d 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -122,6 +122,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "alarm_control_panel", "assist_satellite", "battery", + "calendar", "climate", "cover", "device_tracker", diff --git a/homeassistant/components/calendar/condition.py b/homeassistant/components/calendar/condition.py new file mode 100644 index 00000000000..3055cb1d754 --- /dev/null +++ b/homeassistant/components/calendar/condition.py @@ -0,0 +1,16 @@ +"""Provides conditions for calendars.""" + +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +from .const import DOMAIN + +CONDITIONS: dict[str, type[Condition]] = { + "is_event_active": make_entity_state_condition(DOMAIN, STATE_ON), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the calendar conditions.""" + return CONDITIONS diff --git a/homeassistant/components/calendar/conditions.yaml b/homeassistant/components/calendar/conditions.yaml new file mode 100644 index 00000000000..7452e7ec7fe --- /dev/null +++ b/homeassistant/components/calendar/conditions.yaml @@ -0,0 +1,14 @@ +is_event_active: + target: + entity: + - domain: calendar + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any diff --git a/homeassistant/components/calendar/icons.json b/homeassistant/components/calendar/icons.json index e2faf13658c..f9ae830d300 100644 --- a/homeassistant/components/calendar/icons.json +++ b/homeassistant/components/calendar/icons.json @@ -1,4 +1,9 @@ { + "conditions": { + "is_event_active": { + "condition": "mdi:calendar-check" + } + }, "entity_component": { "_": { "default": "mdi:calendar", diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 0da24e4b97e..8cac1016e80 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -1,4 +1,20 @@ { + "common": { + "condition_behavior_description": "How the state should match on the targeted calendars.", + "condition_behavior_name": "Behavior" + }, + "conditions": { + "is_event_active": { + "description": "Tests if one or more calendars have an active event.", + "fields": { + "behavior": { + "description": "[%key:component::calendar::common::condition_behavior_description%]", + "name": "[%key:component::calendar::common::condition_behavior_name%]" + } + }, + "name": "Calendar event is active" + } + }, "entity_component": { "_": { "name": "[%key:component::calendar::title%]", @@ -46,6 +62,12 @@ } }, "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "trigger_offset_type": { "options": { "after": "After", diff --git a/tests/components/calendar/test_condition.py b/tests/components/calendar/test_condition.py new file mode 100644 index 00000000000..05b7c711314 --- /dev/null +++ b/tests/components/calendar/test_condition.py @@ -0,0 +1,114 @@ +"""Test calendar conditions.""" + +from typing import Any + +import pytest + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_calendars(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple calendar entities associated with different targets.""" + return await target_entities(hass, "calendar") + + +@pytest.mark.parametrize( + "condition", + [ + "calendar.is_event_active", + ], +) +async def test_calendar_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the calendar conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("calendar"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="calendar.is_event_active", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + ], +) +async def test_calendar_condition_behavior_any( + hass: HomeAssistant, + target_calendars: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test calendar condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_calendars, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("calendar"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="calendar.is_event_active", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + ], +) +async def test_calendar_condition_behavior_all( + hass: HomeAssistant, + target_calendars: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test calendar condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_calendars, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) From e353ed1e2e9fe9baf55a38cc3e8f8d7c23693243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 30 Mar 2026 15:41:08 +0100 Subject: [PATCH 119/138] Add counter purpose-specific condition (#166879) --- .../components/automation/__init__.py | 1 + homeassistant/components/counter/condition.py | 15 ++ .../components/counter/conditions.yaml | 25 +++ homeassistant/components/counter/icons.json | 5 + homeassistant/components/counter/strings.json | 22 +++ tests/components/counter/test_condition.py | 177 ++++++++++++++++++ 6 files changed, 245 insertions(+) create mode 100644 homeassistant/components/counter/condition.py create mode 100644 homeassistant/components/counter/conditions.yaml create mode 100644 tests/components/counter/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 7fa8c5afb6d..8887674dcdb 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -124,6 +124,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "battery", "calendar", "climate", + "counter", "cover", "device_tracker", "door", diff --git a/homeassistant/components/counter/condition.py b/homeassistant/components/counter/condition.py new file mode 100644 index 00000000000..ce5aa6b3916 --- /dev/null +++ b/homeassistant/components/counter/condition.py @@ -0,0 +1,15 @@ +"""Provides conditions for counters.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition, make_entity_numerical_condition + +DOMAIN = "counter" + +CONDITIONS: dict[str, type[Condition]] = { + "is_value": make_entity_numerical_condition(DOMAIN), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for counters.""" + return CONDITIONS diff --git a/homeassistant/components/counter/conditions.yaml b/homeassistant/components/counter/conditions.yaml new file mode 100644 index 00000000000..6a00235d287 --- /dev/null +++ b/homeassistant/components/counter/conditions.yaml @@ -0,0 +1,25 @@ +is_value: + target: + entity: + - domain: counter + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + threshold: + required: true + selector: + numeric_threshold: + entity: + - domain: counter + - domain: input_number + - domain: number + mode: is + number: + mode: box diff --git a/homeassistant/components/counter/icons.json b/homeassistant/components/counter/icons.json index fef5b876c73..c2dd1d0afc3 100644 --- a/homeassistant/components/counter/icons.json +++ b/homeassistant/components/counter/icons.json @@ -1,4 +1,9 @@ { + "conditions": { + "is_value": { + "condition": "mdi:counter" + } + }, "services": { "decrement": { "service": "mdi:numeric-negative-1" diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index e09fd1ba9fd..5bede3a676b 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -3,6 +3,22 @@ "trigger_behavior_description": "The behavior of the targeted counters to trigger on.", "trigger_behavior_name": "Behavior" }, + "conditions": { + "is_value": { + "description": "Tests the value of one or more counters.", + "fields": { + "behavior": { + "description": "How the state should match on the targeted counters.", + "name": "Behavior" + }, + "threshold": { + "description": "What to test for and threshold values.", + "name": "Threshold" + } + }, + "name": "Counter value" + } + }, "entity_component": { "_": { "name": "[%key:component::counter::title%]", @@ -30,6 +46,12 @@ } }, "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "trigger_behavior": { "options": { "any": "Any", diff --git a/tests/components/counter/test_condition.py b/tests/components/counter/test_condition.py new file mode 100644 index 00000000000..c25695edbfb --- /dev/null +++ b/tests/components/counter/test_condition.py @@ -0,0 +1,177 @@ +"""Test counter conditions.""" + +from typing import Any + +import pytest + +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_counters(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple counter entities associated with different targets.""" + return await target_entities(hass, "counter") + + +async def test_counter_condition_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the counter condition is gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, "counter.is_value") + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("counter"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="counter.is_value", + condition_options={ + "threshold": {"type": "above", "value": {"number": 20}}, + }, + target_states=["21", "50", "100"], + other_states=["0", "10", "20"], + ), + *parametrize_condition_states_any( + condition="counter.is_value", + condition_options={ + "threshold": {"type": "below", "value": {"number": 20}}, + }, + target_states=["0", "10", "19"], + other_states=["20", "50", "100"], + ), + *parametrize_condition_states_any( + condition="counter.is_value", + condition_options={ + "threshold": { + "type": "between", + "value_min": {"number": 10}, + "value_max": {"number": 30}, + }, + }, + target_states=["11", "20", "29"], + other_states=["0", "10", "30", "100"], + ), + *parametrize_condition_states_any( + condition="counter.is_value", + condition_options={ + "threshold": { + "type": "outside", + "value_min": {"number": 10}, + "value_max": {"number": 30}, + }, + }, + target_states=["0", "10", "30", "100"], + other_states=["11", "20", "29"], + ), + ], +) +async def test_counter_is_value_condition_behavior_any( + hass: HomeAssistant, + target_counters: dict[str, list[str]], + condition_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the counter is_value condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_counters, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("counter"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="counter.is_value", + condition_options={ + "threshold": {"type": "above", "value": {"number": 20}}, + }, + target_states=["21", "50", "100"], + other_states=["0", "10", "20"], + ), + *parametrize_condition_states_all( + condition="counter.is_value", + condition_options={ + "threshold": {"type": "below", "value": {"number": 20}}, + }, + target_states=["0", "10", "19"], + other_states=["20", "50", "100"], + ), + *parametrize_condition_states_all( + condition="counter.is_value", + condition_options={ + "threshold": { + "type": "between", + "value_min": {"number": 10}, + "value_max": {"number": 30}, + }, + }, + target_states=["11", "20", "29"], + other_states=["0", "10", "30", "100"], + ), + *parametrize_condition_states_all( + condition="counter.is_value", + condition_options={ + "threshold": { + "type": "outside", + "value_min": {"number": 10}, + "value_max": {"number": 30}, + }, + }, + target_states=["0", "10", "30", "100"], + other_states=["11", "20", "29"], + ), + ], +) +async def test_counter_is_value_condition_behavior_all( + hass: HomeAssistant, + target_counters: dict[str, list[str]], + condition_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the counter is_value condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_counters, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) From 817d3e1178a308732e615523cb1add3315d5f03f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Mar 2026 17:46:07 +0200 Subject: [PATCH 120/138] Remove redundant field descriptions from triggers and conditions (#166955) --- .../components/air_quality/strings.json | 90 +------------------ .../alarm_control_panel/strings.json | 20 +---- .../components/assist_satellite/strings.json | 14 +-- homeassistant/components/battery/strings.json | 26 +----- .../components/calendar/strings.json | 4 +- homeassistant/components/climate/strings.json | 35 +------- homeassistant/components/counter/strings.json | 12 +-- homeassistant/components/cover/strings.json | 26 +----- .../components/device_tracker/strings.json | 10 +-- homeassistant/components/door/strings.json | 10 +-- homeassistant/components/fan/strings.json | 10 +-- .../components/garage_door/strings.json | 10 +-- homeassistant/components/gate/strings.json | 10 +-- .../components/humidifier/strings.json | 21 +---- .../components/humidity/strings.json | 18 +--- .../components/illuminance/strings.json | 22 +---- .../components/lawn_mower/strings.json | 16 +--- homeassistant/components/light/strings.json | 22 +---- homeassistant/components/lock/strings.json | 14 +-- .../components/media_player/strings.json | 12 +-- .../components/moisture/strings.json | 22 +---- homeassistant/components/motion/strings.json | 10 +-- .../components/occupancy/strings.json | 10 +-- homeassistant/components/person/strings.json | 10 +-- homeassistant/components/power/strings.json | 18 +--- homeassistant/components/remote/strings.json | 5 +- .../components/schedule/strings.json | 10 +-- homeassistant/components/select/strings.json | 3 +- homeassistant/components/siren/strings.json | 10 +-- homeassistant/components/switch/strings.json | 10 +-- .../components/temperature/strings.json | 18 +--- homeassistant/components/text/strings.json | 4 +- homeassistant/components/timer/strings.json | 6 +- homeassistant/components/update/strings.json | 4 +- homeassistant/components/vacuum/strings.json | 16 +--- homeassistant/components/valve/strings.json | 12 +-- .../components/water_heater/strings.json | 24 +---- homeassistant/components/window/strings.json | 10 +-- 38 files changed, 94 insertions(+), 510 deletions(-) diff --git a/homeassistant/components/air_quality/strings.json b/homeassistant/components/air_quality/strings.json index f3369398b34..996bf260057 100644 --- a/homeassistant/components/air_quality/strings.json +++ b/homeassistant/components/air_quality/strings.json @@ -1,25 +1,18 @@ { "common": { - "condition_behavior_description": "How the value should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_co2_value": { "description": "Tests the carbon dioxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -29,7 +22,6 @@ "description": "Tests if one or more carbon monoxide sensors are cleared.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -39,7 +31,6 @@ "description": "Tests if one or more carbon monoxide sensors are detecting carbon monoxide.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -49,11 +40,9 @@ "description": "Tests the carbon monoxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -63,7 +52,6 @@ "description": "Tests if one or more gas sensors are cleared.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -73,7 +61,6 @@ "description": "Tests if one or more gas sensors are detecting gas.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -83,11 +70,9 @@ "description": "Tests the nitrous oxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -97,11 +82,9 @@ "description": "Tests the nitrogen dioxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -111,11 +94,9 @@ "description": "Tests the nitrogen monoxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -125,11 +106,9 @@ "description": "Tests the ozone level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -139,11 +118,9 @@ "description": "Tests the PM10 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -153,11 +130,9 @@ "description": "Tests the PM1 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -167,11 +142,9 @@ "description": "Tests the PM2.5 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -181,11 +154,9 @@ "description": "Tests the PM4 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -195,7 +166,6 @@ "description": "Tests if one or more smoke sensors are cleared.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -205,7 +175,6 @@ "description": "Tests if one or more smoke sensors are detecting smoke.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -215,11 +184,9 @@ "description": "Tests the sulphur dioxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -229,11 +196,9 @@ "description": "Tests the volatile organic compounds ratio of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -243,11 +208,9 @@ "description": "Tests the volatile organic compounds level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -275,7 +238,6 @@ "description": "Triggers after one or more carbon dioxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -285,11 +247,9 @@ "description": "Triggers after one or more carbon dioxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -299,7 +259,6 @@ "description": "Triggers after one or more carbon monoxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -309,7 +268,6 @@ "description": "Triggers after one or more carbon monoxide sensors stop detecting carbon monoxide.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -319,11 +277,9 @@ "description": "Triggers after one or more carbon monoxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -333,7 +289,6 @@ "description": "Triggers after one or more carbon monoxide sensors start detecting carbon monoxide.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -343,7 +298,6 @@ "description": "Triggers after one or more gas sensors stop detecting gas.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -353,7 +307,6 @@ "description": "Triggers after one or more gas sensors start detecting gas.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -363,7 +316,6 @@ "description": "Triggers after one or more nitrous oxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -373,11 +325,9 @@ "description": "Triggers after one or more nitrous oxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -387,7 +337,6 @@ "description": "Triggers after one or more nitrogen dioxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -397,11 +346,9 @@ "description": "Triggers after one or more nitrogen dioxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -411,7 +358,6 @@ "description": "Triggers after one or more nitrogen monoxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -421,11 +367,9 @@ "description": "Triggers after one or more nitrogen monoxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -435,7 +379,6 @@ "description": "Triggers after one or more ozone levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -445,11 +388,9 @@ "description": "Triggers after one or more ozone levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -459,7 +400,6 @@ "description": "Triggers after one or more PM10 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -469,11 +409,9 @@ "description": "Triggers after one or more PM10 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -483,7 +421,6 @@ "description": "Triggers after one or more PM1 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -493,11 +430,9 @@ "description": "Triggers after one or more PM1 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -507,7 +442,6 @@ "description": "Triggers after one or more PM2.5 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -517,11 +451,9 @@ "description": "Triggers after one or more PM2.5 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -531,7 +463,6 @@ "description": "Triggers after one or more PM4 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -541,11 +472,9 @@ "description": "Triggers after one or more PM4 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -555,7 +484,6 @@ "description": "Triggers after one or more smoke sensors stop detecting smoke.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -565,7 +493,6 @@ "description": "Triggers after one or more smoke sensors start detecting smoke.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -575,7 +502,6 @@ "description": "Triggers after one or more sulphur dioxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -585,11 +511,9 @@ "description": "Triggers after one or more sulphur dioxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -599,7 +523,6 @@ "description": "Triggers after one or more volatile organic compound levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -609,11 +532,9 @@ "description": "Triggers after one or more volatile organic compounds levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -623,7 +544,6 @@ "description": "Triggers after one or more volatile organic compound ratios change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -633,11 +553,9 @@ "description": "Triggers after one or more volatile organic compounds ratios cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 63c907d25c6..c49e3d9df33 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted alarms.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted alarms to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_armed": { "description": "Tests if one or more alarms are armed.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more alarms are armed in away mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more alarms are armed in home mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more alarms are armed in night mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -50,7 +44,6 @@ "description": "Tests if one or more alarms are armed in vacation mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -60,7 +53,6 @@ "description": "Tests if one or more alarms are disarmed.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -70,7 +62,6 @@ "description": "Tests if one or more alarms are triggered.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -242,7 +233,6 @@ "description": "Triggers after one or more alarms become armed, regardless of the mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -252,7 +242,6 @@ "description": "Triggers after one or more alarms become armed in away mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -262,7 +251,6 @@ "description": "Triggers after one or more alarms become armed in home mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -272,7 +260,6 @@ "description": "Triggers after one or more alarms become armed in night mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -282,7 +269,6 @@ "description": "Triggers after one or more alarms become armed in vacation mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -292,7 +278,6 @@ "description": "Triggers after one or more alarms become disarmed.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -302,7 +287,6 @@ "description": "Triggers after one or more alarms become triggered.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index f544ddbd8d0..70f167f323e 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted Assist satellites.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_idle": { "description": "Tests if one or more Assist satellites are idle.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more Assist satellites are listening.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more Assist satellites are processing.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more Assist satellites are responding.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" } }, @@ -165,7 +159,6 @@ "description": "Triggers after one or more voice assistant satellites become idle after having processed a command.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" } }, @@ -175,7 +168,6 @@ "description": "Triggers after one or more voice assistant satellites start listening for a command from someone.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" } }, @@ -185,7 +177,6 @@ "description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" } }, @@ -195,7 +186,6 @@ "description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/battery/strings.json b/homeassistant/components/battery/strings.json index dc6c518f665..61f1698bea4 100644 --- a/homeassistant/components/battery/strings.json +++ b/homeassistant/components/battery/strings.json @@ -1,21 +1,15 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted batteries.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted batteries to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_charging": { "description": "Tests if one or more batteries are charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" } }, @@ -25,11 +19,9 @@ "description": "Tests the battery level of one or more batteries.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::battery::common::condition_threshold_description%]", "name": "[%key:component::battery::common::condition_threshold_name%]" } }, @@ -39,7 +31,6 @@ "description": "Tests if one or more batteries are low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" } }, @@ -49,7 +40,6 @@ "description": "Tests if one or more batteries are not charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" } }, @@ -59,7 +49,6 @@ "description": "Tests if one or more batteries are not low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" } }, @@ -87,7 +76,6 @@ "description": "Triggers after the battery level of one or more batteries changes.", "fields": { "threshold": { - "description": "[%key:component::battery::common::trigger_threshold_changed_description%]", "name": "[%key:component::battery::common::trigger_threshold_name%]" } }, @@ -97,11 +85,9 @@ "description": "Triggers after the battery level of one or more batteries crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::battery::common::trigger_threshold_crossed_description%]", "name": "[%key:component::battery::common::trigger_threshold_name%]" } }, @@ -111,7 +97,6 @@ "description": "Triggers after one or more batteries become low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" } }, @@ -121,7 +106,6 @@ "description": "Triggers after one or more batteries are no longer low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" } }, @@ -131,7 +115,6 @@ "description": "Triggers after one or more batteries start charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" } }, @@ -141,7 +124,6 @@ "description": "Triggers after one or more batteries stop charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 8cac1016e80..1175002adc8 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -1,14 +1,12 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted calendars.", - "condition_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if" }, "conditions": { "is_event_active": { "description": "Tests if one or more calendars have an active event.", "fields": { "behavior": { - "description": "[%key:component::calendar::common::condition_behavior_description%]", "name": "[%key:component::calendar::common::condition_behavior_name%]" } }, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 7fc608ff419..2c2947c15ee 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -1,21 +1,15 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted climate-control devices.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted climates to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_cooling": { "description": "Tests if one or more climate-control devices are cooling.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" } }, @@ -25,7 +19,6 @@ "description": "Tests if one or more climate-control devices are drying.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" } }, @@ -35,7 +28,6 @@ "description": "Tests if one or more climate-control devices are heating.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" } }, @@ -45,7 +37,6 @@ "description": "Tests if one or more climate-control devices are set to a specific HVAC mode.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, "hvac_mode": { @@ -59,7 +50,6 @@ "description": "Tests if one or more climate-control devices are off.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" } }, @@ -69,7 +59,6 @@ "description": "Tests if one or more climate-control devices are on.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" } }, @@ -79,11 +68,9 @@ "description": "Tests the humidity setpoint of one or more climate-control devices.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::climate::common::condition_threshold_description%]", "name": "[%key:component::climate::common::condition_threshold_name%]" } }, @@ -93,11 +80,9 @@ "description": "Tests the temperature setpoint of one or more climate-control devices.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::climate::common::condition_threshold_description%]", "name": "[%key:component::climate::common::condition_threshold_name%]" } }, @@ -398,7 +383,6 @@ "description": "Triggers after the mode of one or more climate-control devices changes.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" }, "hvac_mode": { @@ -412,7 +396,6 @@ "description": "Triggers after one or more climate-control devices start cooling.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" } }, @@ -422,7 +405,6 @@ "description": "Triggers after one or more climate-control devices start drying.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" } }, @@ -432,7 +414,6 @@ "description": "Triggers after one or more climate-control devices start heating.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" } }, @@ -442,7 +423,6 @@ "description": "Triggers after the humidity setpoint of one or more climate-control devices changes.", "fields": { "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_changed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, @@ -452,11 +432,9 @@ "description": "Triggers after the humidity setpoint of one or more climate-control devices crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_crossed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, @@ -466,7 +444,6 @@ "description": "Triggers after the temperature setpoint of one or more climate-control devices changes.", "fields": { "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_changed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, @@ -476,11 +453,9 @@ "description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_crossed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, @@ -490,7 +465,6 @@ "description": "Triggers after one or more climate-control devices turn off.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" } }, @@ -500,7 +474,6 @@ "description": "Triggers after one or more climate-control devices turn on, regardless of the mode.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index 5bede3a676b..4e728b0bc44 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -1,19 +1,16 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted counters to trigger on.", - "trigger_behavior_name": "Behavior" + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_value": { "description": "Tests the value of one or more counters.", "fields": { "behavior": { - "description": "How the state should match on the targeted counters.", - "name": "Behavior" + "name": "Condition passes if" }, "threshold": { - "description": "What to test for and threshold values.", - "name": "Threshold" + "name": "Threshold type" } }, "name": "Counter value" @@ -98,7 +95,6 @@ "description": "Triggers after one or more counters reach their maximum value.", "fields": { "behavior": { - "description": "[%key:component::counter::common::trigger_behavior_description%]", "name": "[%key:component::counter::common::trigger_behavior_name%]" } }, @@ -108,7 +104,6 @@ "description": "Triggers after one or more counters reach their minimum value.", "fields": { "behavior": { - "description": "[%key:component::counter::common::trigger_behavior_description%]", "name": "[%key:component::counter::common::trigger_behavior_name%]" } }, @@ -118,7 +113,6 @@ "description": "Triggers after one or more counters are reset.", "fields": { "behavior": { - "description": "[%key:component::counter::common::trigger_behavior_description%]", "name": "[%key:component::counter::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 30a25185aab..3be0ed28d79 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted covers.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted covers to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "awning_is_closed": { "description": "Tests if one or more awnings are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more awnings are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more blinds are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more blinds are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -50,7 +44,6 @@ "description": "Tests if one or more curtains are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -60,7 +53,6 @@ "description": "Tests if one or more curtains are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -70,7 +62,6 @@ "description": "Tests if one or more shades are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -80,7 +71,6 @@ "description": "Tests if one or more shades are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -90,7 +80,6 @@ "description": "Tests if one or more shutters are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -100,7 +89,6 @@ "description": "Tests if one or more shutters are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -265,7 +253,6 @@ "description": "Triggers after one or more awnings close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -275,7 +262,6 @@ "description": "Triggers after one or more awnings open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -285,7 +271,6 @@ "description": "Triggers after one or more blinds close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -295,7 +280,6 @@ "description": "Triggers after one or more blinds open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -305,7 +289,6 @@ "description": "Triggers after one or more curtains close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -315,7 +298,6 @@ "description": "Triggers after one or more curtains open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -325,7 +307,6 @@ "description": "Triggers after one or more shades close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -335,7 +316,6 @@ "description": "Triggers after one or more shades open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -345,7 +325,6 @@ "description": "Triggers after one or more shutters close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -355,7 +334,6 @@ "description": "Triggers after one or more shutters open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index f4f7031fa79..ff71fb30c65 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted device trackers.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_home": { "description": "Tests if one or more device trackers are home.", "fields": { "behavior": { - "description": "[%key:component::device_tracker::common::condition_behavior_description%]", "name": "[%key:component::device_tracker::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more device trackers are not home.", "fields": { "behavior": { - "description": "[%key:component::device_tracker::common::condition_behavior_description%]", "name": "[%key:component::device_tracker::common::condition_behavior_name%]" } }, @@ -129,7 +125,6 @@ "description": "Triggers when one or more device trackers enter home.", "fields": { "behavior": { - "description": "[%key:component::device_tracker::common::trigger_behavior_description%]", "name": "[%key:component::device_tracker::common::trigger_behavior_name%]" } }, @@ -139,7 +134,6 @@ "description": "Triggers when one or more device trackers leave home.", "fields": { "behavior": { - "description": "[%key:component::device_tracker::common::trigger_behavior_description%]", "name": "[%key:component::device_tracker::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/door/strings.json b/homeassistant/components/door/strings.json index 8cad12e0299..c6e5961ceff 100644 --- a/homeassistant/components/door/strings.json +++ b/homeassistant/components/door/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted doors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted doors to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_closed": { "description": "Tests if one or more doors are closed.", "fields": { "behavior": { - "description": "[%key:component::door::common::condition_behavior_description%]", "name": "[%key:component::door::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more doors are open.", "fields": { "behavior": { - "description": "[%key:component::door::common::condition_behavior_description%]", "name": "[%key:component::door::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more doors close.", "fields": { "behavior": { - "description": "[%key:component::door::common::trigger_behavior_description%]", "name": "[%key:component::door::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more doors open.", "fields": { "behavior": { - "description": "[%key:component::door::common::trigger_behavior_description%]", "name": "[%key:component::door::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index efeb6efa3fc..51a05b6bf4c 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted fans.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted fans to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_off": { "description": "Tests if one or more fans are off.", "fields": { "behavior": { - "description": "[%key:component::fan::common::condition_behavior_description%]", "name": "[%key:component::fan::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more fans are on.", "fields": { "behavior": { - "description": "[%key:component::fan::common::condition_behavior_description%]", "name": "[%key:component::fan::common::condition_behavior_name%]" } }, @@ -199,7 +195,6 @@ "description": "Triggers after one or more fans turn off.", "fields": { "behavior": { - "description": "[%key:component::fan::common::trigger_behavior_description%]", "name": "[%key:component::fan::common::trigger_behavior_name%]" } }, @@ -209,7 +204,6 @@ "description": "Triggers after one or more fans turn on.", "fields": { "behavior": { - "description": "[%key:component::fan::common::trigger_behavior_description%]", "name": "[%key:component::fan::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/garage_door/strings.json b/homeassistant/components/garage_door/strings.json index f0e50ad82a1..574a117f517 100644 --- a/homeassistant/components/garage_door/strings.json +++ b/homeassistant/components/garage_door/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted garage doors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_closed": { "description": "Tests if one or more garage doors are closed.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::condition_behavior_description%]", "name": "[%key:component::garage_door::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more garage doors are open.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::condition_behavior_description%]", "name": "[%key:component::garage_door::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more garage doors close.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::trigger_behavior_description%]", "name": "[%key:component::garage_door::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more garage doors open.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::trigger_behavior_description%]", "name": "[%key:component::garage_door::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/gate/strings.json b/homeassistant/components/gate/strings.json index 134e9bb108f..ed1f04b0fc6 100644 --- a/homeassistant/components/gate/strings.json +++ b/homeassistant/components/gate/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted gates.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted gates to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_closed": { "description": "Tests if one or more gates are closed.", "fields": { "behavior": { - "description": "[%key:component::gate::common::condition_behavior_description%]", "name": "[%key:component::gate::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more gates are open.", "fields": { "behavior": { - "description": "[%key:component::gate::common::condition_behavior_description%]", "name": "[%key:component::gate::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more gates close.", "fields": { "behavior": { - "description": "[%key:component::gate::common::trigger_behavior_description%]", "name": "[%key:component::gate::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more gates open.", "fields": { "behavior": { - "description": "[%key:component::gate::common::trigger_behavior_description%]", "name": "[%key:component::gate::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 82ae8b57436..ff7f28a2e5f 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -1,18 +1,14 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted humidifiers.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted humidifiers to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_drying": { "description": "Tests if one or more humidifiers are drying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" } }, @@ -22,7 +18,6 @@ "description": "Tests if one or more humidifiers are humidifying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" } }, @@ -32,7 +27,6 @@ "description": "Tests if one or more humidifiers are set to a specific mode.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" }, "mode": { @@ -46,7 +40,6 @@ "description": "Tests if one or more humidifiers are off.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" } }, @@ -56,7 +49,6 @@ "description": "Tests if one or more humidifiers are on.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" } }, @@ -66,11 +58,9 @@ "description": "Tests the target humidity of one or more humidifiers.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::humidifier::common::condition_threshold_description%]", "name": "[%key:component::humidifier::common::condition_threshold_name%]" } }, @@ -219,7 +209,6 @@ "description": "Triggers after the operation mode of one or more humidifiers changes.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" }, "mode": { @@ -233,7 +222,6 @@ "description": "Triggers after one or more humidifiers start drying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" } }, @@ -243,7 +231,6 @@ "description": "Triggers after one or more humidifiers start humidifying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" } }, @@ -253,7 +240,6 @@ "description": "Triggers after one or more humidifiers turn off.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" } }, @@ -263,7 +249,6 @@ "description": "Triggers after one or more humidifiers turn on.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/humidity/strings.json b/homeassistant/components/humidity/strings.json index 06836f05dce..20df0ca139a 100644 --- a/homeassistant/components/humidity/strings.json +++ b/homeassistant/components/humidity/strings.json @@ -1,25 +1,18 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_value": { "description": "Tests if a relative humidity value is above a threshold, below a threshold, or in a range of values.", "fields": { "behavior": { - "description": "[%key:component::humidity::common::condition_behavior_description%]", "name": "[%key:component::humidity::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::humidity::common::condition_threshold_description%]", "name": "[%key:component::humidity::common::condition_threshold_name%]" } }, @@ -47,7 +40,6 @@ "description": "Triggers after one or more relative humidity values change.", "fields": { "threshold": { - "description": "[%key:component::humidity::common::trigger_threshold_changed_description%]", "name": "[%key:component::humidity::common::trigger_threshold_name%]" } }, @@ -57,11 +49,9 @@ "description": "Triggers after one or more relative humidity values cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::humidity::common::trigger_behavior_description%]", "name": "[%key:component::humidity::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::humidity::common::trigger_threshold_crossed_description%]", "name": "[%key:component::humidity::common::trigger_threshold_name%]" } }, diff --git a/homeassistant/components/illuminance/strings.json b/homeassistant/components/illuminance/strings.json index 5ed11170df0..e1c478fff9f 100644 --- a/homeassistant/components/illuminance/strings.json +++ b/homeassistant/components/illuminance/strings.json @@ -1,21 +1,15 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_detected": { "description": "Tests if light is currently detected.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::condition_behavior_description%]", "name": "[%key:component::illuminance::common::condition_behavior_name%]" } }, @@ -25,7 +19,6 @@ "description": "Tests if light is currently not detected.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::condition_behavior_description%]", "name": "[%key:component::illuminance::common::condition_behavior_name%]" } }, @@ -35,11 +28,9 @@ "description": "Tests the illuminance value.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::condition_behavior_description%]", "name": "[%key:component::illuminance::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::illuminance::common::condition_threshold_description%]", "name": "[%key:component::illuminance::common::condition_threshold_name%]" } }, @@ -67,7 +58,6 @@ "description": "Triggers after one or more illuminance values change.", "fields": { "threshold": { - "description": "[%key:component::illuminance::common::trigger_threshold_changed_description%]", "name": "[%key:component::illuminance::common::trigger_threshold_name%]" } }, @@ -77,7 +67,6 @@ "description": "Triggers after one or more light sensors stop detecting light.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::trigger_behavior_description%]", "name": "[%key:component::illuminance::common::trigger_behavior_name%]" } }, @@ -87,11 +76,9 @@ "description": "Triggers after one or more illuminance values cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::trigger_behavior_description%]", "name": "[%key:component::illuminance::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::illuminance::common::trigger_threshold_crossed_description%]", "name": "[%key:component::illuminance::common::trigger_threshold_name%]" } }, @@ -101,7 +88,6 @@ "description": "Triggers after one or more light sensors start detecting light.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::trigger_behavior_description%]", "name": "[%key:component::illuminance::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index e882b260aeb..973d046979a 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted lawn mowers.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted lawn mowers to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_docked": { "description": "Tests if one or more lawn mowers are docked.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more lawn mowers are encountering an error.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more lawn mowers are mowing.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more lawn mowers are paused.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" } }, @@ -50,7 +44,6 @@ "description": "Tests if one or more lawn mowers are returning to the dock.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" } }, @@ -104,7 +97,6 @@ "description": "Triggers after one or more lawn mowers have returned to dock.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" } }, @@ -114,7 +106,6 @@ "description": "Triggers after one or more lawn mowers encounter an error.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" } }, @@ -124,7 +115,6 @@ "description": "Triggers after one or more lawn mowers pause mowing.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" } }, @@ -134,7 +124,6 @@ "description": "Triggers after one or more lawn mowers start mowing.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" } }, @@ -144,7 +133,6 @@ "description": "Triggers after one or more lawn mowers start returning to dock.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index dd0ae383c92..69356bb4ad8 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -1,9 +1,7 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted lights.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", "field_brightness_description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness.", "field_brightness_name": "Brightness value", "field_brightness_pct_description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness.", @@ -37,22 +35,17 @@ "field_xy_color_description": "Color in XY-format. A list of two decimal numbers between 0 and 1.", "field_xy_color_name": "XY-color", "section_advanced_fields_name": "Advanced options", - "trigger_behavior_description": "The behavior of the targeted lights to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_brightness": { "description": "Tests the brightness of one or more lights.", "fields": { "behavior": { - "description": "[%key:component::light::common::condition_behavior_description%]", "name": "[%key:component::light::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::light::common::condition_threshold_description%]", "name": "[%key:component::light::common::condition_threshold_name%]" } }, @@ -62,7 +55,6 @@ "description": "Tests if one or more lights are off.", "fields": { "behavior": { - "description": "[%key:component::light::common::condition_behavior_description%]", "name": "[%key:component::light::common::condition_behavior_name%]" } }, @@ -72,7 +64,6 @@ "description": "Tests if one or more lights are on.", "fields": { "behavior": { - "description": "[%key:component::light::common::condition_behavior_description%]", "name": "[%key:component::light::common::condition_behavior_name%]" } }, @@ -513,7 +504,6 @@ "description": "Triggers after the brightness of one or more lights changes.", "fields": { "threshold": { - "description": "[%key:component::light::common::trigger_threshold_changed_description%]", "name": "[%key:component::light::common::trigger_threshold_name%]" } }, @@ -523,11 +513,9 @@ "description": "Triggers after the brightness of one or more lights crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::light::common::trigger_behavior_description%]", "name": "[%key:component::light::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::light::common::trigger_threshold_crossed_description%]", "name": "[%key:component::light::common::trigger_threshold_name%]" } }, @@ -537,7 +525,6 @@ "description": "Triggers after one or more lights turn off.", "fields": { "behavior": { - "description": "[%key:component::light::common::trigger_behavior_description%]", "name": "[%key:component::light::common::trigger_behavior_name%]" } }, @@ -547,7 +534,6 @@ "description": "Triggers after one or more lights turn on.", "fields": { "behavior": { - "description": "[%key:component::light::common::trigger_behavior_description%]", "name": "[%key:component::light::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index fea8afdfb04..b53a2f92cf3 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted locks.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted locks to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_jammed": { "description": "Tests if one or more locks are jammed.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more locks are locked.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more locks are open.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more locks are unlocked.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" } }, @@ -151,7 +145,6 @@ "description": "Triggers after one or more locks jam.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "name": "[%key:component::lock::common::trigger_behavior_name%]" } }, @@ -161,7 +154,6 @@ "description": "Triggers after one or more locks lock.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "name": "[%key:component::lock::common::trigger_behavior_name%]" } }, @@ -171,7 +163,6 @@ "description": "Triggers after one or more locks open.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "name": "[%key:component::lock::common::trigger_behavior_name%]" } }, @@ -181,7 +172,6 @@ "description": "Triggers after one or more locks unlock.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "name": "[%key:component::lock::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 8ff5d13b225..d9ef8c05103 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted media players.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted media players to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_not_playing": { "description": "Tests if one or more media players are not playing.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more media players are off.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more media players are on.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more media players are paused.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" } }, @@ -50,7 +44,6 @@ "description": "Tests if one or more media players are playing.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" } }, @@ -444,7 +437,6 @@ "description": "Triggers after one or more media players stop playing media.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::trigger_behavior_description%]", "name": "[%key:component::media_player::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/moisture/strings.json b/homeassistant/components/moisture/strings.json index c2f9705bcca..d125ccf9a5b 100644 --- a/homeassistant/components/moisture/strings.json +++ b/homeassistant/components/moisture/strings.json @@ -1,21 +1,15 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_detected": { "description": "Tests if one or more moisture sensors are detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::condition_behavior_description%]", "name": "[%key:component::moisture::common::condition_behavior_name%]" } }, @@ -25,7 +19,6 @@ "description": "Tests if one or more moisture sensors are not detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::condition_behavior_description%]", "name": "[%key:component::moisture::common::condition_behavior_name%]" } }, @@ -35,11 +28,9 @@ "description": "Tests the moisture level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::condition_behavior_description%]", "name": "[%key:component::moisture::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::moisture::common::condition_threshold_description%]", "name": "[%key:component::moisture::common::condition_threshold_name%]" } }, @@ -67,7 +58,6 @@ "description": "Triggers after one or more moisture content values change.", "fields": { "threshold": { - "description": "[%key:component::moisture::common::trigger_threshold_changed_description%]", "name": "[%key:component::moisture::common::trigger_threshold_name%]" } }, @@ -77,7 +67,6 @@ "description": "Triggers after one or more moisture sensors stop detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::trigger_behavior_description%]", "name": "[%key:component::moisture::common::trigger_behavior_name%]" } }, @@ -87,11 +76,9 @@ "description": "Triggers after one or more moisture content values cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::trigger_behavior_description%]", "name": "[%key:component::moisture::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::moisture::common::trigger_threshold_crossed_description%]", "name": "[%key:component::moisture::common::trigger_threshold_name%]" } }, @@ -101,7 +88,6 @@ "description": "Triggers after one or more moisture sensors start detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::trigger_behavior_description%]", "name": "[%key:component::moisture::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/motion/strings.json b/homeassistant/components/motion/strings.json index cf810f0065c..44f8703d83d 100644 --- a/homeassistant/components/motion/strings.json +++ b/homeassistant/components/motion/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted motion sensors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted motion sensors to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_detected": { "description": "Tests if one or more motion sensors are detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::condition_behavior_description%]", "name": "[%key:component::motion::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more motion sensors are not detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::condition_behavior_description%]", "name": "[%key:component::motion::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more motion sensors stop detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::trigger_behavior_description%]", "name": "[%key:component::motion::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more motion sensors start detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::trigger_behavior_description%]", "name": "[%key:component::motion::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/occupancy/strings.json b/homeassistant/components/occupancy/strings.json index b93743b2bb8..062dfa8e336 100644 --- a/homeassistant/components/occupancy/strings.json +++ b/homeassistant/components/occupancy/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted occupancy sensors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted occupancy sensors to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_detected": { "description": "Tests if one or more occupancy sensors are detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::condition_behavior_description%]", "name": "[%key:component::occupancy::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more occupancy sensors are not detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::condition_behavior_description%]", "name": "[%key:component::occupancy::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more occupancy sensors stop detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::trigger_behavior_description%]", "name": "[%key:component::occupancy::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more occupancy sensors start detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::trigger_behavior_description%]", "name": "[%key:component::occupancy::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/person/strings.json b/homeassistant/components/person/strings.json index 24b430197f8..af211e373a7 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted persons.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted persons to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_home": { "description": "Tests if one or more persons are home.", "fields": { "behavior": { - "description": "[%key:component::person::common::condition_behavior_description%]", "name": "[%key:component::person::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more persons are not home.", "fields": { "behavior": { - "description": "[%key:component::person::common::condition_behavior_description%]", "name": "[%key:component::person::common::condition_behavior_name%]" } }, @@ -80,7 +76,6 @@ "description": "Triggers when one or more persons enter home.", "fields": { "behavior": { - "description": "[%key:component::person::common::trigger_behavior_description%]", "name": "[%key:component::person::common::trigger_behavior_name%]" } }, @@ -90,7 +85,6 @@ "description": "Triggers when one or more persons leave home.", "fields": { "behavior": { - "description": "[%key:component::person::common::trigger_behavior_description%]", "name": "[%key:component::person::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/power/strings.json b/homeassistant/components/power/strings.json index f4369b0e225..9be4af702e5 100644 --- a/homeassistant/components/power/strings.json +++ b/homeassistant/components/power/strings.json @@ -1,25 +1,18 @@ { "common": { - "condition_behavior_description": "How the power value should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_value": { "description": "Tests the power value of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::power::common::condition_behavior_description%]", "name": "[%key:component::power::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::power::common::condition_threshold_description%]", "name": "[%key:component::power::common::condition_threshold_name%]" } }, @@ -47,7 +40,6 @@ "description": "Triggers after one or more power values change.", "fields": { "threshold": { - "description": "[%key:component::power::common::trigger_threshold_changed_description%]", "name": "[%key:component::power::common::trigger_threshold_name%]" } }, @@ -57,11 +49,9 @@ "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%]" }, "threshold": { - "description": "[%key:component::power::common::trigger_threshold_crossed_description%]", "name": "[%key:component::power::common::trigger_threshold_name%]" } }, diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index e2f6af02673..8cad5e289ac 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -1,7 +1,6 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted remotes to trigger on.", - "trigger_behavior_name": "Behavior" + "trigger_behavior_name": "Trigger when" }, "device_automation": { "action_type": { @@ -132,7 +131,6 @@ "description": "Triggers when one or more remotes turn off.", "fields": { "behavior": { - "description": "[%key:component::remote::common::trigger_behavior_description%]", "name": "[%key:component::remote::common::trigger_behavior_name%]" } }, @@ -142,7 +140,6 @@ "description": "Triggers when one or more remotes turn on.", "fields": { "behavior": { - "description": "[%key:component::remote::common::trigger_behavior_description%]", "name": "[%key:component::remote::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index bb51bd39dc0..b8d3581a696 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted schedules.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted schedules to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_off": { "description": "Tests if one or more schedule blocks are currently not active.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::condition_behavior_description%]", "name": "[%key:component::schedule::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more schedule blocks are currently active.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::condition_behavior_description%]", "name": "[%key:component::schedule::common::condition_behavior_name%]" } }, @@ -79,7 +75,6 @@ "description": "Triggers when a schedule block ends.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::trigger_behavior_description%]", "name": "[%key:component::schedule::common::trigger_behavior_name%]" } }, @@ -89,7 +84,6 @@ "description": "Triggers when a schedule block starts.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::trigger_behavior_description%]", "name": "[%key:component::schedule::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index cac07327f53..77f6d51a7fb 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -4,8 +4,7 @@ "description": "Tests if one or more dropdowns have a specific option selected.", "fields": { "behavior": { - "description": "Whether the condition should pass when any or all targeted entities match.", - "name": "Behavior" + "name": "Condition passes if" }, "option": { "description": "The options to check for.", diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json index b33c2592255..e20c3421736 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted sirens.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted sirens to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_off": { "description": "Tests if one or more sirens are off.", "fields": { "behavior": { - "description": "[%key:component::siren::common::condition_behavior_description%]", "name": "[%key:component::siren::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more sirens are on.", "fields": { "behavior": { - "description": "[%key:component::siren::common::condition_behavior_description%]", "name": "[%key:component::siren::common::condition_behavior_name%]" } }, @@ -90,7 +86,6 @@ "description": "Triggers after one or more sirens turn off.", "fields": { "behavior": { - "description": "[%key:component::siren::common::trigger_behavior_description%]", "name": "[%key:component::siren::common::trigger_behavior_name%]" } }, @@ -100,7 +95,6 @@ "description": "Triggers after one or more sirens turn on.", "fields": { "behavior": { - "description": "[%key:component::siren::common::trigger_behavior_description%]", "name": "[%key:component::siren::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index 93b406e4eb1..40f629b9e64 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted switches.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted switches to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_off": { "description": "Tests if one or more switches are off.", "fields": { "behavior": { - "description": "[%key:component::switch::common::condition_behavior_description%]", "name": "[%key:component::switch::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more switches are on.", "fields": { "behavior": { - "description": "[%key:component::switch::common::condition_behavior_description%]", "name": "[%key:component::switch::common::condition_behavior_name%]" } }, @@ -104,7 +100,6 @@ "description": "Triggers after one or more switches turn off.", "fields": { "behavior": { - "description": "[%key:component::switch::common::trigger_behavior_description%]", "name": "[%key:component::switch::common::trigger_behavior_name%]" } }, @@ -114,7 +109,6 @@ "description": "Triggers after one or more switches turn on.", "fields": { "behavior": { - "description": "[%key:component::switch::common::trigger_behavior_description%]", "name": "[%key:component::switch::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/temperature/strings.json b/homeassistant/components/temperature/strings.json index e1c74365759..c970474b78e 100644 --- a/homeassistant/components/temperature/strings.json +++ b/homeassistant/components/temperature/strings.json @@ -1,25 +1,18 @@ { "common": { - "condition_behavior_description": "How the temperature should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_value": { "description": "Tests the temperature of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::temperature::common::condition_behavior_description%]", "name": "[%key:component::temperature::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::temperature::common::condition_threshold_description%]", "name": "[%key:component::temperature::common::condition_threshold_name%]" } }, @@ -47,7 +40,6 @@ "description": "Triggers after one or more temperatures change.", "fields": { "threshold": { - "description": "[%key:component::temperature::common::trigger_threshold_changed_description%]", "name": "[%key:component::temperature::common::trigger_threshold_name%]" } }, @@ -57,11 +49,9 @@ "description": "Triggers after one or more temperatures cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::temperature::common::trigger_behavior_description%]", "name": "[%key:component::temperature::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::temperature::common::trigger_threshold_crossed_description%]", "name": "[%key:component::temperature::common::trigger_threshold_name%]" } }, diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index e7fea2f230e..0eae84e3013 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -1,14 +1,12 @@ { "common": { - "condition_behavior_description": "The behavior of the targeted texts to check.", - "condition_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if" }, "conditions": { "is_equal_to": { "description": "Tests if one or more texts are equal to a specified value.", "fields": { "behavior": { - "description": "[%key:component::text::common::condition_behavior_description%]", "name": "[%key:component::text::common::condition_behavior_name%]" }, "value": { diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index b1373b4764e..4774247e912 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,14 +1,12 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted timers.", - "condition_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if" }, "conditions": { "is_active": { "description": "Tests if one or more timers are active.", "fields": { "behavior": { - "description": "[%key:component::timer::common::condition_behavior_description%]", "name": "[%key:component::timer::common::condition_behavior_name%]" } }, @@ -18,7 +16,6 @@ "description": "Tests if one or more timers are idle.", "fields": { "behavior": { - "description": "[%key:component::timer::common::condition_behavior_description%]", "name": "[%key:component::timer::common::condition_behavior_name%]" } }, @@ -28,7 +25,6 @@ "description": "Tests if one or more timers are paused.", "fields": { "behavior": { - "description": "[%key:component::timer::common::condition_behavior_description%]", "name": "[%key:component::timer::common::condition_behavior_name%]" } }, diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index c0851796efe..7634d59a3c3 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -1,7 +1,6 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted updates to become available.", - "trigger_behavior_name": "Behavior" + "trigger_behavior_name": "Trigger when" }, "device_automation": { "extra_fields": { @@ -98,7 +97,6 @@ "description": "Triggers after one or more updates become available.", "fields": { "behavior": { - "description": "[%key:component::update::common::trigger_behavior_description%]", "name": "[%key:component::update::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index ebd0febdbed..364a4bfef0e 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted vacuum cleaners.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted vacuum cleaners to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_cleaning": { "description": "Tests if one or more vacuum cleaners are cleaning.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more vacuum cleaners are docked.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more vacuum cleaners are encountering an error.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more vacuum cleaners are paused.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" } }, @@ -50,7 +44,6 @@ "description": "Tests if one or more vacuum cleaners are returning to the dock.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" } }, @@ -197,7 +190,6 @@ "description": "Triggers after one or more vacuums have returned to dock.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" } }, @@ -207,7 +199,6 @@ "description": "Triggers after one or more vacuums encounter an error.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" } }, @@ -217,7 +208,6 @@ "description": "Triggers after one or more vacuums pause cleaning.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" } }, @@ -227,7 +217,6 @@ "description": "Triggers after one or more vacuums start cleaning.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" } }, @@ -237,7 +226,6 @@ "description": "Triggers after one or more vacuums start returning to dock.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index cd01e3142cf..3775f38fb18 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -1,15 +1,14 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted valves to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_closed": { "description": "Tests if one or more valves are closed.", "fields": { "behavior": { - "description": "Whether the condition should pass when any or all targeted entities match.", - "name": "Behavior" + "name": "[%key:component::valve::common::condition_behavior_name%]" } }, "name": "Valve is closed" @@ -18,8 +17,7 @@ "description": "Tests if one or more valves are open.", "fields": { "behavior": { - "description": "Whether the condition should pass when any or all targeted entities match.", - "name": "Behavior" + "name": "[%key:component::valve::common::condition_behavior_name%]" } }, "name": "Valve is open" @@ -97,7 +95,6 @@ "description": "Triggers after one or more valves close.", "fields": { "behavior": { - "description": "[%key:component::valve::common::trigger_behavior_description%]", "name": "[%key:component::valve::common::trigger_behavior_name%]" } }, @@ -107,7 +104,6 @@ "description": "Triggers after one or more valves open.", "fields": { "behavior": { - "description": "[%key:component::valve::common::trigger_behavior_description%]", "name": "[%key:component::valve::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 46362df0654..1e7da70662a 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -1,21 +1,15 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted water heaters.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted water heaters to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_off": { "description": "Tests if one or more water heaters are off.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" } }, @@ -25,7 +19,6 @@ "description": "Tests if one or more water heaters are on.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" } }, @@ -35,7 +28,6 @@ "description": "Tests if one or more water heaters are set to a specific operation mode.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" }, "operation_mode": { @@ -49,11 +41,9 @@ "description": "Tests the temperature setpoint of one or more water heaters.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::water_heater::common::condition_threshold_description%]", "name": "[%key:component::water_heater::common::condition_threshold_name%]" } }, @@ -192,7 +182,6 @@ "description": "Triggers after the operation mode of one or more water heaters changes to a specific mode.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "name": "[%key:component::water_heater::common::trigger_behavior_name%]" }, "operation_mode": { @@ -206,7 +195,6 @@ "description": "Triggers after the temperature setpoint of one or more water heaters changes.", "fields": { "threshold": { - "description": "[%key:component::water_heater::common::trigger_threshold_changed_description%]", "name": "[%key:component::water_heater::common::trigger_threshold_name%]" } }, @@ -216,11 +204,9 @@ "description": "Triggers after the temperature setpoint of one or more water heaters crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "name": "[%key:component::water_heater::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::water_heater::common::trigger_threshold_crossed_description%]", "name": "[%key:component::water_heater::common::trigger_threshold_name%]" } }, @@ -230,7 +216,6 @@ "description": "Triggers after one or more water heaters turn off.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "name": "[%key:component::water_heater::common::trigger_behavior_name%]" } }, @@ -240,7 +225,6 @@ "description": "Triggers after one or more water heaters turn on, regardless of the operation mode.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "name": "[%key:component::water_heater::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/window/strings.json b/homeassistant/components/window/strings.json index b0b4d3f4aef..5f8de98998f 100644 --- a/homeassistant/components/window/strings.json +++ b/homeassistant/components/window/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted windows.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted windows to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_closed": { "description": "Tests if one or more windows are closed.", "fields": { "behavior": { - "description": "[%key:component::window::common::condition_behavior_description%]", "name": "[%key:component::window::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more windows are open.", "fields": { "behavior": { - "description": "[%key:component::window::common::condition_behavior_description%]", "name": "[%key:component::window::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more windows close.", "fields": { "behavior": { - "description": "[%key:component::window::common::trigger_behavior_description%]", "name": "[%key:component::window::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more windows open.", "fields": { "behavior": { - "description": "[%key:component::window::common::trigger_behavior_description%]", "name": "[%key:component::window::common::trigger_behavior_name%]" } }, From a616de745281ace0eabaf27f7c4b75584cf95f02 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 Mar 2026 18:26:51 +0200 Subject: [PATCH 121/138] Update frontend to 20260325.4 (#166970) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4c8256a82e6..a87ec738fe0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.2"] + "requirements": ["home-assistant-frontend==20260325.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 430e8d81f1c..e6417021b0d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.11.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.2 +home-assistant-frontend==20260325.4 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1250d8942f3..e4c37c8dd34 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.2 +home-assistant-frontend==20260325.4 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 467420f92cc..889648de153 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.2 +home-assistant-frontend==20260325.4 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From d9babc37f02ba97adca126e635da7fec66eb700a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 Mar 2026 20:00:43 +0200 Subject: [PATCH 122/138] Bump version to 2026.4.0b8 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f1a5b32e032..10335630c37 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index 4ba845e8120..898ce020da1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0b7" +version = "2026.4.0b8" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From aaff319e70db0e5168d2f4723fae2bc99597c25a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 31 Mar 2026 20:00:37 +0200 Subject: [PATCH 123/138] Fix grammar of `input_shutdown_failure` error in `victron_ble` (#166972) --- homeassistant/components/victron_ble/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/victron_ble/strings.json b/homeassistant/components/victron_ble/strings.json index c599e61a83a..901594b473e 100644 --- a/homeassistant/components/victron_ble/strings.json +++ b/homeassistant/components/victron_ble/strings.json @@ -145,7 +145,7 @@ "input_current": "Input overcurrent", "input_power": "Input overpower", "input_shutdown_current": "Input shutdown (current flow during off mode)", - "input_shutdown_failure": "PV input failed to shutdown", + "input_shutdown_failure": "PV input shutdown failed", "input_shutdown_voltage": "Input shutdown (battery overvoltage)", "input_voltage": "Input overvoltage", "internal_dc_voltage": "Internal DC voltage error", From 9650aea6a13e060122f6668ab444d768e55061f8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 31 Mar 2026 19:59:28 +0200 Subject: [PATCH 124/138] Make sure we can fetch player stats in Chess.com (#166980) --- homeassistant/components/chess_com/config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/chess_com/config_flow.py b/homeassistant/components/chess_com/config_flow.py index 687d331b1dd..fea9ffd94df 100644 --- a/homeassistant/components/chess_com/config_flow.py +++ b/homeassistant/components/chess_com/config_flow.py @@ -30,6 +30,7 @@ class ChessConfigFlow(ConfigFlow, domain=DOMAIN): client = ChessComClient(session=session) try: user = await client.get_player(user_input[CONF_USERNAME]) + await client.get_player_stats(user_input[CONF_USERNAME]) except NotFoundError: errors["base"] = "player_not_found" except Exception: From 12dc33eabc4da1e27f55aeecff9cb47fbb0f2e25 Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:31:45 +0200 Subject: [PATCH 125/138] Add skeleton with repair issue to bmw integration (#166983) Co-authored-by: Franck Nijhof Co-authored-by: Martin Hjelmare --- .../bmw_connected_drive/__init__.py | 41 ++++++++++ .../bmw_connected_drive/config_flow.py | 9 +++ .../bmw_connected_drive/manifest.json | 10 +++ .../bmw_connected_drive/strings.json | 8 ++ script/hassfest/quality_scale.py | 2 + .../bmw_connected_drive/__init__.py | 1 + .../bmw_connected_drive/test_init.py | 79 +++++++++++++++++++ 7 files changed, 150 insertions(+) create mode 100644 homeassistant/components/bmw_connected_drive/__init__.py create mode 100644 homeassistant/components/bmw_connected_drive/config_flow.py create mode 100644 homeassistant/components/bmw_connected_drive/manifest.json create mode 100644 homeassistant/components/bmw_connected_drive/strings.json create mode 100644 tests/components/bmw_connected_drive/__init__.py create mode 100644 tests/components/bmw_connected_drive/test_init.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py new file mode 100644 index 00000000000..16133b5a1c1 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -0,0 +1,41 @@ +"""The BMW Connected Drive integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +DOMAIN = "bmw_connected_drive" + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up BMW Connected Drive from a config entry.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/bmw_connected_drive", + "custom_component_url": "https://github.com/kvanbiesen/bmw-cardata-ha", + }, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py new file mode 100644 index 00000000000..7295864c29c --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -0,0 +1,9 @@ +"""The BMW Connected Drive integration config flow.""" + +from homeassistant.config_entries import ConfigFlow + +from . import DOMAIN + + +class BMWConnectedDriveConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for BMW Connected Drive.""" diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json new file mode 100644 index 00000000000..b1c3cc9769b --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bmw_connected_drive", + "name": "BMW Connected Drive", + "codeowners": [], + "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", + "integration_type": "system", + "iot_class": "cloud_polling", + "quality_scale": "legacy", + "requirements": [] +} diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json new file mode 100644 index 00000000000..7ff1b1eb99c --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "integration_removed": { + "description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).", + "title": "The BMW Connected Drive integration has been removed" + } + } +} diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 94b23e32b55..8ff2f158598 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -212,6 +212,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "bluetooth", "bluetooth_adapters", "bluetooth_le_tracker", + "bmw_connected_drive", "bond", "bosch_shc", "braviatv", @@ -1183,6 +1184,7 @@ INTEGRATIONS_WITHOUT_SCALE = [ "bluetooth", "bluetooth_adapters", "bluetooth_le_tracker", + "bmw_connected_drive", "bond", "bosch_shc", "braviatv", diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py new file mode 100644 index 00000000000..265e4b0c4f5 --- /dev/null +++ b/tests/components/bmw_connected_drive/__init__.py @@ -0,0 +1 @@ +"""Tests for the BMW Connected Drive integration.""" diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py new file mode 100644 index 00000000000..c05c98d07f7 --- /dev/null +++ b/tests/components/bmw_connected_drive/test_init.py @@ -0,0 +1,79 @@ +"""Tests for the BMW Connected Drive integration.""" + +from homeassistant.components.bmw_connected_drive import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_bmw_connected_drive_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the BMW Connected Drive configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) From 35826dfd1420802b9144dc2a51f0b7e7e12b3ddc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 31 Mar 2026 20:40:04 +0200 Subject: [PATCH 126/138] Pull out Dropbox integration (#166986) --- .strict-typing | 1 - CODEOWNERS | 2 - homeassistant/components/dropbox/__init__.py | 64 -- .../dropbox/application_credentials.py | 38 -- homeassistant/components/dropbox/auth.py | 44 -- homeassistant/components/dropbox/backup.py | 230 ------- .../components/dropbox/config_flow.py | 60 -- homeassistant/components/dropbox/const.py | 19 - .../components/dropbox/manifest.json | 13 - .../components/dropbox/quality_scale.yaml | 112 ---- homeassistant/components/dropbox/strings.json | 35 -- .../generated/application_credentials.py | 1 - homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - mypy.ini | 10 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/dropbox/__init__.py | 1 - tests/components/dropbox/conftest.py | 114 ---- tests/components/dropbox/test_backup.py | 577 ------------------ tests/components/dropbox/test_config_flow.py | 210 ------- tests/components/dropbox/test_init.py | 100 --- 22 files changed, 1644 deletions(-) delete mode 100644 homeassistant/components/dropbox/__init__.py delete mode 100644 homeassistant/components/dropbox/application_credentials.py delete mode 100644 homeassistant/components/dropbox/auth.py delete mode 100644 homeassistant/components/dropbox/backup.py delete mode 100644 homeassistant/components/dropbox/config_flow.py delete mode 100644 homeassistant/components/dropbox/const.py delete mode 100644 homeassistant/components/dropbox/manifest.json delete mode 100644 homeassistant/components/dropbox/quality_scale.yaml delete mode 100644 homeassistant/components/dropbox/strings.json delete mode 100644 tests/components/dropbox/__init__.py delete mode 100644 tests/components/dropbox/conftest.py delete mode 100644 tests/components/dropbox/test_backup.py delete mode 100644 tests/components/dropbox/test_config_flow.py delete mode 100644 tests/components/dropbox/test_init.py diff --git a/.strict-typing b/.strict-typing index 87e2e85eeb8..e811362e91f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -174,7 +174,6 @@ homeassistant.components.dnsip.* homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* homeassistant.components.downloader.* -homeassistant.components.dropbox.* homeassistant.components.droplet.* homeassistant.components.dsmr.* homeassistant.components.duckdns.* diff --git a/CODEOWNERS b/CODEOWNERS index f73c4561383..03f67311ad2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -401,8 +401,6 @@ build.json @home-assistant/supervisor /tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer -/homeassistant/components/dropbox/ @bdr99 -/tests/components/dropbox/ @bdr99 /homeassistant/components/droplet/ @sarahseidman /tests/components/droplet/ @sarahseidman /homeassistant/components/dsmr/ @Robbie1221 diff --git a/homeassistant/components/dropbox/__init__.py b/homeassistant/components/dropbox/__init__.py deleted file mode 100644 index 4be8074a5cd..00000000000 --- a/homeassistant/components/dropbox/__init__.py +++ /dev/null @@ -1,64 +0,0 @@ -"""The Dropbox integration.""" - -from __future__ import annotations - -from python_dropbox_api import ( - DropboxAPIClient, - DropboxAuthException, - DropboxUnknownException, -) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.config_entry_oauth2_flow import ( - ImplementationUnavailableError, - OAuth2Session, - async_get_config_entry_implementation, -) - -from .auth import DropboxConfigEntryAuth -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN - -type DropboxConfigEntry = ConfigEntry[DropboxAPIClient] - - -async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool: - """Set up Dropbox from a config entry.""" - try: - oauth2_implementation = await async_get_config_entry_implementation(hass, entry) - except ImplementationUnavailableError as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="oauth2_implementation_unavailable", - ) from err - oauth2_session = OAuth2Session(hass, entry, oauth2_implementation) - - auth = DropboxConfigEntryAuth( - aiohttp_client.async_get_clientsession(hass), oauth2_session - ) - - client = DropboxAPIClient(auth) - - try: - await client.get_account_info() - except DropboxAuthException as err: - raise ConfigEntryAuthFailed from err - except (DropboxUnknownException, TimeoutError) as err: - raise ConfigEntryNotReady from err - - entry.runtime_data = client - - def async_notify_backup_listeners() -> None: - for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): - listener() - - entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool: - """Unload a config entry.""" - return True diff --git a/homeassistant/components/dropbox/application_credentials.py b/homeassistant/components/dropbox/application_credentials.py deleted file mode 100644 index 3babe856a28..00000000000 --- a/homeassistant/components/dropbox/application_credentials.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Application credentials platform for the Dropbox integration.""" - -from homeassistant.components.application_credentials import ClientCredential -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import ( - AbstractOAuth2Implementation, - LocalOAuth2ImplementationWithPkce, -) - -from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN - - -async def async_get_auth_implementation( - hass: HomeAssistant, auth_domain: str, credential: ClientCredential -) -> AbstractOAuth2Implementation: - """Return custom auth implementation.""" - return DropboxOAuth2Implementation( - hass, - auth_domain, - credential.client_id, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, - credential.client_secret, - ) - - -class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce): - """Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters.""" - - @property - def extra_authorize_data(self) -> dict: - """Extra data that needs to be appended to the authorize url.""" - data: dict = { - "token_access_type": "offline", - "scope": " ".join(OAUTH2_SCOPES), - } - data.update(super().extra_authorize_data) - return data diff --git a/homeassistant/components/dropbox/auth.py b/homeassistant/components/dropbox/auth.py deleted file mode 100644 index da6d72f6748..00000000000 --- a/homeassistant/components/dropbox/auth.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Authentication for Dropbox.""" - -from typing import cast - -from aiohttp import ClientSession -from python_dropbox_api import Auth - -from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session - - -class DropboxConfigEntryAuth(Auth): - """Provide Dropbox authentication tied to an OAuth2 based config entry.""" - - def __init__( - self, - websession: ClientSession, - oauth_session: OAuth2Session, - ) -> None: - """Initialize DropboxConfigEntryAuth.""" - super().__init__(websession) - self._oauth_session = oauth_session - - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - await self._oauth_session.async_ensure_token_valid() - - return cast(str, self._oauth_session.token["access_token"]) - - -class DropboxConfigFlowAuth(Auth): - """Provide authentication tied to a fixed token for the config flow.""" - - def __init__( - self, - websession: ClientSession, - token: str, - ) -> None: - """Initialize DropboxConfigFlowAuth.""" - super().__init__(websession) - self._token = token - - async def async_get_access_token(self) -> str: - """Return the fixed access token.""" - return self._token diff --git a/homeassistant/components/dropbox/backup.py b/homeassistant/components/dropbox/backup.py deleted file mode 100644 index bc7af3d5cbc..00000000000 --- a/homeassistant/components/dropbox/backup.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Backup platform for the Dropbox integration.""" - -from collections.abc import AsyncIterator, Callable, Coroutine -from functools import wraps -import json -import logging -from typing import Any, Concatenate - -from python_dropbox_api import ( - DropboxAPIClient, - DropboxAuthException, - DropboxFileOrFolderNotFoundException, - DropboxUnknownException, -) - -from homeassistant.components.backup import ( - AgentBackup, - BackupAgent, - BackupAgentError, - BackupNotFound, - suggested_filename, -) -from homeassistant.core import HomeAssistant, callback - -from . import DropboxConfigEntry -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]: - """Return the suggested filenames for the backup and metadata.""" - base_name = suggested_filename(backup).rsplit(".", 1)[0] - return f"{base_name}.tar", f"{base_name}.metadata.json" - - -async def _async_string_iterator(content: str) -> AsyncIterator[bytes]: - """Yield a string as a single bytes chunk.""" - yield content.encode() - - -def handle_backup_errors[_R, **P]( - func: Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]], -) -> Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]]: - """Handle backup errors.""" - - @wraps(func) - async def wrapper( - self: DropboxBackupAgent, *args: P.args, **kwargs: P.kwargs - ) -> _R: - try: - return await func(self, *args, **kwargs) - except DropboxFileOrFolderNotFoundException as err: - raise BackupNotFound( - f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}" - ) from err - except DropboxAuthException as err: - self._entry.async_start_reauth(self._hass) - raise BackupAgentError("Authentication error") from err - except DropboxUnknownException as err: - _LOGGER.error( - "Error during %s: %s", - func.__name__, - err, - ) - _LOGGER.debug("Full error: %s", err, exc_info=True) - raise BackupAgentError( - f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}" - ) from err - - return wrapper - - -async def async_get_backup_agents( - hass: HomeAssistant, - **kwargs: Any, -) -> list[BackupAgent]: - """Return a list of backup agents.""" - entries = hass.config_entries.async_loaded_entries(DOMAIN) - return [DropboxBackupAgent(hass, entry) for entry in entries] - - -@callback -def async_register_backup_agents_listener( - hass: HomeAssistant, - *, - listener: Callable[[], None], - **kwargs: Any, -) -> Callable[[], None]: - """Register a listener to be called when agents are added or removed. - - :return: A function to unregister the listener. - """ - hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) - - @callback - def remove_listener() -> None: - """Remove the listener.""" - hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) - if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: - del hass.data[DATA_BACKUP_AGENT_LISTENERS] - - return remove_listener - - -class DropboxBackupAgent(BackupAgent): - """Backup agent for the Dropbox integration.""" - - domain = DOMAIN - - def __init__(self, hass: HomeAssistant, entry: DropboxConfigEntry) -> None: - """Initialize the backup agent.""" - super().__init__() - self._hass = hass - self._entry = entry - self.name = entry.title - assert entry.unique_id - self.unique_id = entry.unique_id - self._api: DropboxAPIClient = entry.runtime_data - - async def _async_get_backups(self) -> list[tuple[AgentBackup, str]]: - """Get backups and their corresponding file names.""" - files = await self._api.list_folder("") - - tar_files = {f.name for f in files if f.name.endswith(".tar")} - metadata_files = [f for f in files if f.name.endswith(".metadata.json")] - - backups: list[tuple[AgentBackup, str]] = [] - for metadata_file in metadata_files: - tar_name = metadata_file.name.removesuffix(".metadata.json") + ".tar" - if tar_name not in tar_files: - _LOGGER.warning( - "Found metadata file '%s' without matching backup file", - metadata_file.name, - ) - continue - - metadata_stream = self._api.download_file(f"/{metadata_file.name}") - raw = b"".join([chunk async for chunk in metadata_stream]) - try: - data = json.loads(raw) - backup = AgentBackup.from_dict(data) - except (json.JSONDecodeError, ValueError, TypeError, KeyError) as err: - _LOGGER.warning( - "Skipping invalid metadata file '%s': %s", - metadata_file.name, - err, - ) - continue - backups.append((backup, tar_name)) - - return backups - - @handle_backup_errors - async def async_upload_backup( - self, - *, - open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - backup: AgentBackup, - **kwargs: Any, - ) -> None: - """Upload a backup.""" - backup_filename, metadata_filename = _suggested_filenames(backup) - backup_path = f"/{backup_filename}" - metadata_path = f"/{metadata_filename}" - - file_stream = await open_stream() - await self._api.upload_file(backup_path, file_stream) - - metadata_stream = _async_string_iterator(json.dumps(backup.as_dict())) - - try: - await self._api.upload_file(metadata_path, metadata_stream) - except ( - DropboxAuthException, - DropboxUnknownException, - ): - await self._api.delete_file(backup_path) - raise - - @handle_backup_errors - async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: - """List backups.""" - return [backup for backup, _ in await self._async_get_backups()] - - @handle_backup_errors - async def async_download_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AsyncIterator[bytes]: - """Download a backup file.""" - backups = await self._async_get_backups() - for backup, filename in backups: - if backup.backup_id == backup_id: - return self._api.download_file(f"/{filename}") - - raise BackupNotFound(f"Backup {backup_id} not found") - - @handle_backup_errors - async def async_get_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AgentBackup: - """Return a backup.""" - backups = await self._async_get_backups() - - for backup, _ in backups: - if backup.backup_id == backup_id: - return backup - - raise BackupNotFound(f"Backup {backup_id} not found") - - @handle_backup_errors - async def async_delete_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> None: - """Delete a backup file.""" - backups = await self._async_get_backups() - for backup, tar_filename in backups: - if backup.backup_id == backup_id: - metadata_filename = tar_filename.removesuffix(".tar") + ".metadata.json" - await self._api.delete_file(f"/{tar_filename}") - await self._api.delete_file(f"/{metadata_filename}") - return - - raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/dropbox/config_flow.py b/homeassistant/components/dropbox/config_flow.py deleted file mode 100644 index 045f858bd59..00000000000 --- a/homeassistant/components/dropbox/config_flow.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Config flow for Dropbox.""" - -from collections.abc import Mapping -import logging -from typing import Any - -from python_dropbox_api import DropboxAPIClient - -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler - -from .auth import DropboxConfigFlowAuth -from .const import DOMAIN - - -class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): - """Config flow to handle Dropbox OAuth2 authentication.""" - - DOMAIN = DOMAIN - - @property - def logger(self) -> logging.Logger: - """Return logger.""" - return logging.getLogger(__name__) - - async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: - """Create an entry for the flow, or update existing entry.""" - access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN] - - auth = DropboxConfigFlowAuth(async_get_clientsession(self.hass), access_token) - - client = DropboxAPIClient(auth) - account_info = await client.get_account_info() - - await self.async_set_unique_id(account_info.account_id) - if self.source == SOURCE_REAUTH: - self._abort_if_unique_id_mismatch(reason="wrong_account") - return self.async_update_reload_and_abort( - self._get_reauth_entry(), data=data - ) - - self._abort_if_unique_id_configured() - - return self.async_create_entry(title=account_info.email, data=data) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Perform reauth upon an API authentication error.""" - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Dialog that informs the user that reauth is required.""" - if user_input is None: - return self.async_show_form(step_id="reauth_confirm") - return await self.async_step_user() diff --git a/homeassistant/components/dropbox/const.py b/homeassistant/components/dropbox/const.py deleted file mode 100644 index 042f5b5c7bf..00000000000 --- a/homeassistant/components/dropbox/const.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Constants for the Dropbox integration.""" - -from collections.abc import Callable - -from homeassistant.util.hass_dict import HassKey - -DOMAIN = "dropbox" - -OAUTH2_AUTHORIZE = "https://www.dropbox.com/oauth2/authorize" -OAUTH2_TOKEN = "https://api.dropboxapi.com/oauth2/token" -OAUTH2_SCOPES = [ - "account_info.read", - "files.content.read", - "files.content.write", -] - -DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( - f"{DOMAIN}.backup_agent_listeners" -) diff --git a/homeassistant/components/dropbox/manifest.json b/homeassistant/components/dropbox/manifest.json deleted file mode 100644 index 01254682b79..00000000000 --- a/homeassistant/components/dropbox/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "domain": "dropbox", - "name": "Dropbox", - "after_dependencies": ["backup"], - "codeowners": ["@bdr99"], - "config_flow": true, - "dependencies": ["application_credentials"], - "documentation": "https://www.home-assistant.io/integrations/dropbox", - "integration_type": "service", - "iot_class": "cloud_polling", - "quality_scale": "bronze", - "requirements": ["python-dropbox-api==0.1.3"] -} diff --git a/homeassistant/components/dropbox/quality_scale.yaml b/homeassistant/components/dropbox/quality_scale.yaml deleted file mode 100644 index 3f46b70b7a5..00000000000 --- a/homeassistant/components/dropbox/quality_scale.yaml +++ /dev/null @@ -1,112 +0,0 @@ -rules: - # Bronze - action-setup: - status: exempt - comment: Integration does not register any actions. - appropriate-polling: - status: exempt - comment: Integration does not poll. - brands: done - common-modules: - status: exempt - comment: Integration does not have any entities or coordinators. - config-flow-test-coverage: done - config-flow: done - dependency-transparency: done - docs-actions: - status: exempt - comment: Integration does not register any actions. - docs-high-level-description: done - docs-installation-instructions: done - docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: Integration does not have any entities. - entity-unique-id: - status: exempt - comment: Integration does not have any entities. - has-entity-name: - status: exempt - comment: Integration does not have any entities. - runtime-data: done - test-before-configure: done - test-before-setup: done - unique-config-entry: done - - # Silver - action-exceptions: - status: exempt - comment: Integration does not register any actions. - config-entry-unloading: done - docs-configuration-parameters: - status: exempt - comment: Integration does not have any configuration parameters. - docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: Integration does not have any entities. - integration-owner: done - log-when-unavailable: todo - parallel-updates: - status: exempt - comment: Integration does not make any entity updates. - reauthentication-flow: done - test-coverage: done - - # Gold - devices: - status: exempt - comment: Integration does not have any entities. - diagnostics: - status: exempt - comment: Integration does not have any data to diagnose. - discovery-update-info: - status: exempt - comment: Integration is a service. - discovery: - status: exempt - comment: Integration is a service. - docs-data-update: - status: exempt - comment: Integration does not update any data. - docs-examples: - status: exempt - comment: Integration only provides backup functionality. - docs-known-limitations: todo - docs-supported-devices: - status: exempt - comment: Integration does not support any devices. - docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: done - dynamic-devices: - status: exempt - comment: Integration does not use any devices. - entity-category: - status: exempt - comment: Integration does not have any entities. - entity-device-class: - status: exempt - comment: Integration does not have any entities. - entity-disabled-by-default: - status: exempt - comment: Integration does not have any entities. - entity-translations: - status: exempt - comment: Integration does not have any entities. - exception-translations: todo - icon-translations: - status: exempt - comment: Integration does not have any entities. - reconfiguration-flow: todo - repair-issues: - status: exempt - comment: Integration does not have any repairs. - stale-devices: - status: exempt - comment: Integration does not have any devices. - - # Platinum - async-dependency: done - inject-websession: done - strict-typing: done diff --git a/homeassistant/components/dropbox/strings.json b/homeassistant/components/dropbox/strings.json deleted file mode 100644 index 4904f997e31..00000000000 --- a/homeassistant/components/dropbox/strings.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "wrong_account": "Wrong account: Please authenticate with the correct account." - }, - "create_entry": { - "default": "[%key:common::config_flow::create_entry::authenticated%]" - }, - "step": { - "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" - }, - "reauth_confirm": { - "description": "The Dropbox integration needs to re-authenticate your account.", - "title": "[%key:common::config_flow::title::reauth%]" - } - } - }, - "exceptions": { - "oauth2_implementation_unavailable": { - "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" - } - } -} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index a520338e916..51435aac0bb 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -6,7 +6,6 @@ To update, run python3 -m script.hassfest APPLICATION_CREDENTIALS = [ "aladdin_connect", "august", - "dropbox", "ekeybionyx", "electric_kiwi", "fitbit", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a9abead1473..82e09a98b3e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -160,7 +160,6 @@ FLOWS = { "downloader", "dremel_3d_printer", "drop_connect", - "dropbox", "droplet", "dsmr", "dsmr_reader", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 79b8133084f..20aeb88c3e6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1479,12 +1479,6 @@ "config_flow": true, "iot_class": "local_push" }, - "dropbox": { - "name": "Dropbox", - "integration_type": "service", - "config_flow": true, - "iot_class": "cloud_polling" - }, "droplet": { "name": "Droplet", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index e0f08ce3787..e07733a7c8a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1496,16 +1496,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.dropbox.*] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - [mypy-homeassistant.components.droplet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index e4c37c8dd34..05db6cf0cc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2568,9 +2568,6 @@ python-clementine-remote==1.0.1 # homeassistant.components.digital_ocean python-digitalocean==1.13.2 -# homeassistant.components.dropbox -python-dropbox-api==0.1.3 - # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 889648de153..0092c94d6bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2188,9 +2188,6 @@ python-awair==0.2.5 # homeassistant.components.bsblan python-bsblan==5.1.3 -# homeassistant.components.dropbox -python-dropbox-api==0.1.3 - # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/tests/components/dropbox/__init__.py b/tests/components/dropbox/__init__.py deleted file mode 100644 index 505d840280e..00000000000 --- a/tests/components/dropbox/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Dropbox integration.""" diff --git a/tests/components/dropbox/conftest.py b/tests/components/dropbox/conftest.py deleted file mode 100644 index a5c324c2be5..00000000000 --- a/tests/components/dropbox/conftest.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Shared fixtures for Dropbox integration tests.""" - -from __future__ import annotations - -from collections.abc import Generator -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) -from homeassistant.components.dropbox.const import DOMAIN, OAUTH2_SCOPES -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry - -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" -ACCOUNT_ID = "dbid:1234567890abcdef" -ACCOUNT_EMAIL = "user@example.com" -CONFIG_ENTRY_TITLE = "Dropbox test account" -TEST_AGENT_ID = f"{DOMAIN}.{ACCOUNT_ID}" - - -@pytest.fixture(autouse=True) -async def setup_credentials(hass: HomeAssistant) -> None: - """Set up application credentials for Dropbox.""" - - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - - -@pytest.fixture -def account_info() -> SimpleNamespace: - """Return mocked Dropbox account information.""" - - return SimpleNamespace(account_id=ACCOUNT_ID, email=ACCOUNT_EMAIL) - - -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return a default Dropbox config entry.""" - - return MockConfigEntry( - domain=DOMAIN, - unique_id=ACCOUNT_ID, - title=CONFIG_ENTRY_TITLE, - data={ - "auth_implementation": DOMAIN, - "token": { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "expires_at": 9_999_999_999, - "scope": " ".join(OAUTH2_SCOPES), - }, - }, - ) - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - - with patch( - "homeassistant.components.dropbox.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture -def mock_dropbox_client(account_info: SimpleNamespace) -> Generator[MagicMock]: - """Patch DropboxAPIClient to exercise auth while mocking API calls.""" - - client = MagicMock() - client.list_folder = AsyncMock(return_value=[]) - client.download_file = MagicMock() - client.upload_file = AsyncMock() - client.delete_file = AsyncMock() - - captured_auth = None - - def capture_auth(auth): - nonlocal captured_auth - captured_auth = auth - return client - - async def get_account_info_with_auth(): - await captured_auth.async_get_access_token() - return client.get_account_info.return_value - - client.get_account_info = AsyncMock( - side_effect=get_account_info_with_auth, - return_value=account_info, - ) - - with ( - patch( - "homeassistant.components.dropbox.config_flow.DropboxAPIClient", - side_effect=capture_auth, - ), - patch( - "homeassistant.components.dropbox.DropboxAPIClient", - side_effect=capture_auth, - ), - ): - yield client diff --git a/tests/components/dropbox/test_backup.py b/tests/components/dropbox/test_backup.py deleted file mode 100644 index 804a37ef3ee..00000000000 --- a/tests/components/dropbox/test_backup.py +++ /dev/null @@ -1,577 +0,0 @@ -"""Test the Dropbox backup platform.""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from io import StringIO -import json -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from python_dropbox_api import DropboxAuthException - -from homeassistant.components.backup import ( - DOMAIN as BACKUP_DOMAIN, - AddonInfo, - AgentBackup, - suggested_filename, -) -from homeassistant.components.dropbox.backup import ( - DropboxFileOrFolderNotFoundException, - DropboxUnknownException, - async_register_backup_agents_listener, -) -from homeassistant.components.dropbox.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID - -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import mock_stream -from tests.typing import ClientSessionGenerator, WebSocketGenerator - -TEST_AGENT_BACKUP = AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id="dropbox-backup", - database_included=True, - date="2025-01-01T00:00:00.000Z", - extra_metadata={"with_automatic_settings": False}, - folders=[], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Dropbox backup", - protected=False, - size=2048, -) - -TEST_AGENT_BACKUP_RESULT = { - "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], - "agents": {TEST_AGENT_ID: {"protected": False, "size": 2048}}, - "backup_id": TEST_AGENT_BACKUP.backup_id, - "database_included": True, - "date": TEST_AGENT_BACKUP.date, - "extra_metadata": {"with_automatic_settings": False}, - "failed_addons": [], - "failed_agent_ids": [], - "failed_folders": [], - "folders": [], - "homeassistant_included": True, - "homeassistant_version": TEST_AGENT_BACKUP.homeassistant_version, - "name": TEST_AGENT_BACKUP.name, - "with_automatic_settings": None, -} - - -def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]: - """Return the suggested filenames for the backup and metadata.""" - base_name = suggested_filename(backup).rsplit(".", 1)[0] - return f"{base_name}.tar", f"{base_name}.metadata.json" - - -async def _mock_metadata_stream(backup: AgentBackup) -> AsyncIterator[bytes]: - """Create a mock metadata download stream.""" - yield json.dumps(backup.as_dict()).encode() - - -def _setup_list_folder_with_backup( - mock_dropbox_client: Mock, - backup: AgentBackup, -) -> None: - """Set up mock to return a backup in list_folder and download_file.""" - tar_name, metadata_name = _suggested_filenames(backup) - mock_dropbox_client.list_folder = AsyncMock( - return_value=[ - SimpleNamespace(name=tar_name), - SimpleNamespace(name=metadata_name), - ] - ) - mock_dropbox_client.download_file = Mock(return_value=_mock_metadata_stream(backup)) - - -@pytest.fixture(autouse=True) -async def setup_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_dropbox_client, -) -> None: - """Set up the Dropbox and Backup integrations for testing.""" - - mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - mock_dropbox_client.reset_mock() - - -async def test_agents_info( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_config_entry: MockConfigEntry, -) -> None: - """Test listing available backup agents.""" - - client = await hass_ws_client(hass) - - await client.send_json_auto_id({"type": "backup/agents/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == { - "agents": [ - {"agent_id": "backup.local", "name": "local"}, - {"agent_id": TEST_AGENT_ID, "name": CONFIG_ENTRY_TITLE}, - ] - } - - await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - await client.send_json_auto_id({"type": "backup/agents/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == { - "agents": [{"agent_id": "backup.local", "name": "local"}] - } - - -async def test_agents_list_backups( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test listing backups via the Dropbox agent.""" - - _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - assert response["result"]["backups"] == [TEST_AGENT_BACKUP_RESULT] - mock_dropbox_client.list_folder.assert_awaited() - - -async def test_agents_list_backups_metadata_without_tar( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that orphaned metadata files are skipped with a warning.""" - - mock_dropbox_client.list_folder = AsyncMock( - return_value=[SimpleNamespace(name="orphan.metadata.json")] - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - assert response["result"]["backups"] == [] - assert "without matching backup file" in caplog.text - - -async def test_agents_list_backups_invalid_metadata( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that invalid metadata files are skipped with a warning.""" - - async def _invalid_stream() -> AsyncIterator[bytes]: - yield b"not valid json" - - mock_dropbox_client.list_folder = AsyncMock( - return_value=[ - SimpleNamespace(name="backup.tar"), - SimpleNamespace(name="backup.metadata.json"), - ] - ) - mock_dropbox_client.download_file = Mock(return_value=_invalid_stream()) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - assert response["result"]["backups"] == [] - assert "Skipping invalid metadata file" in caplog.text - - -async def test_agents_list_backups_fail( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test handling list backups failures.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxUnknownException("boom") - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["backups"] == [] - assert response["result"]["agent_errors"] == { - TEST_AGENT_ID: "Failed to list backups" - } - - -async def test_agents_list_backups_reauth( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test reauthentication is triggered on auth error.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxAuthException("auth failed") - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["backups"] == [] - assert response["result"]["agent_errors"] == {TEST_AGENT_ID: "Authentication error"} - - await hass.async_block_till_done() - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - - flow = flows[0] - assert flow["step_id"] == "reauth_confirm" - assert flow["handler"] == DOMAIN - assert flow["context"]["source"] == SOURCE_REAUTH - assert flow["context"]["entry_id"] == mock_config_entry.entry_id - - -@pytest.mark.parametrize( - "backup_id", - [TEST_AGENT_BACKUP.backup_id, "other-backup"], - ids=["found", "not_found"], -) -async def test_agents_get_backup( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - backup_id: str, -) -> None: - """Test retrieving a backup's metadata.""" - - _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - if backup_id == TEST_AGENT_BACKUP.backup_id: - assert response["result"]["backup"] == TEST_AGENT_BACKUP_RESULT - else: - assert response["result"]["backup"] is None - - -async def test_agents_download( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test downloading a backup file.""" - - tar_name, metadata_name = _suggested_filenames(TEST_AGENT_BACKUP) - - mock_dropbox_client.list_folder = AsyncMock( - return_value=[ - SimpleNamespace(name=tar_name), - SimpleNamespace(name=metadata_name), - ] - ) - - def download_side_effect(path: str) -> AsyncIterator[bytes]: - if path == f"/{tar_name}": - return mock_stream(b"backup data") - return _mock_metadata_stream(TEST_AGENT_BACKUP) - - mock_dropbox_client.download_file = Mock(side_effect=download_side_effect) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 200 - assert await resp.content.read() == b"backup data" - - -async def test_agents_download_fail( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test handling download failures.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxUnknownException("boom") - ) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 500 - body = await resp.content.read() - assert b"Failed to get backup" in body - - -async def test_agents_download_not_found( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test download when backup disappears between get and download.""" - - tar_name, metadata_name = _suggested_filenames(TEST_AGENT_BACKUP) - files = [ - SimpleNamespace(name=tar_name), - SimpleNamespace(name=metadata_name), - ] - - # First list_folder call (async_get_backup) returns the backup; - # second call (async_download_backup) returns empty, simulating deletion. - mock_dropbox_client.list_folder = AsyncMock(side_effect=[files, []]) - mock_dropbox_client.download_file = Mock( - return_value=_mock_metadata_stream(TEST_AGENT_BACKUP) - ) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 404 - assert await resp.content.read() == b"" - - -async def test_agents_download_file_not_found( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test download when Dropbox file is not found returns 404.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxFileOrFolderNotFoundException("not found") - ) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 404 - - -async def test_agents_download_metadata_not_found( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test download when metadata lookup fails.""" - - mock_dropbox_client.list_folder = AsyncMock(return_value=[]) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 404 - assert await resp.content.read() == b"" - - -async def test_agents_upload( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_dropbox_client: Mock, -) -> None: - """Test uploading a backup to Dropbox.""" - - mock_dropbox_client.upload_file = AsyncMock(return_value=None) - - client = await hass_client() - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch("pathlib.Path.open") as mocked_open, - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - resp = await client.post( - f"/api/backup/upload?agent_id={TEST_AGENT_ID}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert f"Uploading backup {TEST_AGENT_BACKUP.backup_id} to agents" in caplog.text - assert mock_dropbox_client.upload_file.await_count == 2 - - -async def test_agents_upload_fail( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_dropbox_client: Mock, -) -> None: - """Test that backup tar is cleaned up when metadata upload fails.""" - - call_count = 0 - - async def upload_side_effect(path: str, stream: AsyncIterator[bytes]) -> None: - nonlocal call_count - call_count += 1 - async for _ in stream: - pass - if call_count == 2: - raise DropboxUnknownException("metadata upload failed") - - mock_dropbox_client.upload_file = AsyncMock(side_effect=upload_side_effect) - mock_dropbox_client.delete_file = AsyncMock() - - client = await hass_client() - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch("pathlib.Path.open") as mocked_open, - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - resp = await client.post( - f"/api/backup/upload?agent_id={TEST_AGENT_ID}", - data={"file": StringIO("test")}, - ) - await hass.async_block_till_done() - - assert resp.status == 201 - assert "Failed to upload backup" in caplog.text - mock_dropbox_client.delete_file.assert_awaited_once() - - -async def test_agents_delete( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test deleting a backup.""" - - _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) - mock_dropbox_client.delete_file = AsyncMock(return_value=None) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": TEST_AGENT_BACKUP.backup_id, - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == {"agent_errors": {}} - assert mock_dropbox_client.delete_file.await_count == 2 - - -async def test_agents_delete_fail( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test error handling when delete fails.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxUnknownException("boom") - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": TEST_AGENT_BACKUP.backup_id, - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == { - "agent_errors": {TEST_AGENT_ID: "Failed to delete backup"} - } - - -async def test_agents_delete_not_found( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test deleting a backup that does not exist.""" - - mock_dropbox_client.list_folder = AsyncMock(return_value=[]) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": TEST_AGENT_BACKUP.backup_id, - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == {"agent_errors": {}} - - -async def test_remove_backup_agents_listener( - hass: HomeAssistant, -) -> None: - """Test removing a backup agent listener.""" - listener = Mock() - remove = async_register_backup_agents_listener(hass, listener=listener) - - assert DATA_BACKUP_AGENT_LISTENERS in hass.data - assert listener in hass.data[DATA_BACKUP_AGENT_LISTENERS] - - # Remove all other listeners to test the cleanup path - hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener] - - remove() - - assert DATA_BACKUP_AGENT_LISTENERS not in hass.data diff --git a/tests/components/dropbox/test_config_flow.py b/tests/components/dropbox/test_config_flow.py deleted file mode 100644 index 9be36ecf0f4..00000000000 --- a/tests/components/dropbox/test_config_flow.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Test the Dropbox config flow.""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest -from yarl import URL - -from homeassistant import config_entries -from homeassistant.components.dropbox.const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_SCOPES, - OAUTH2_TOKEN, -) -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_entry_oauth2_flow - -from .conftest import ACCOUNT_EMAIL, ACCOUNT_ID, CLIENT_ID - -from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - - -@pytest.mark.usefixtures("current_request_with_host") -async def test_full_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_dropbox_client, - mock_setup_entry: AsyncMock, -) -> None: - """Test creating a new config entry through the OAuth flow.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - result_url = URL(result["url"]) - assert f"{result_url.origin()}{result_url.path}" == OAUTH2_AUTHORIZE - assert result_url.query["response_type"] == "code" - assert result_url.query["client_id"] == CLIENT_ID - assert ( - result_url.query["redirect_uri"] == "https://example.com/auth/external/callback" - ) - assert result_url.query["state"] == state - assert result_url.query["scope"] == " ".join(OAUTH2_SCOPES) - assert result_url.query["token_access_type"] == "offline" - assert result_url.query["code_challenge"] - assert result_url.query["code_challenge_method"] == "S256" - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == 200 - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "token_type": "Bearer", - "expires_in": 60, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == ACCOUNT_EMAIL - assert result["data"]["token"]["access_token"] == "mock-access-token" - assert result["result"].unique_id == ACCOUNT_ID - assert len(mock_setup_entry.mock_calls) == 1 - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - -@pytest.mark.usefixtures("current_request_with_host") -async def test_already_configured( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_config_entry, - mock_dropbox_client, -) -> None: - """Test aborting when the account is already configured.""" - - mock_config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == 200 - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "token_type": "Bearer", - "expires_in": 60, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -@pytest.mark.usefixtures("current_request_with_host") -@pytest.mark.parametrize( - ( - "new_account_info", - "expected_reason", - "expected_setup_calls", - "expected_access_token", - ), - [ - ( - SimpleNamespace(account_id=ACCOUNT_ID, email=ACCOUNT_EMAIL), - "reauth_successful", - 1, - "updated-access-token", - ), - ( - SimpleNamespace(account_id="dbid:different", email="other@example.com"), - "wrong_account", - 0, - "mock-access-token", - ), - ], - ids=["success", "wrong_account"], -) -async def test_reauth_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_config_entry, - mock_dropbox_client, - mock_setup_entry: AsyncMock, - new_account_info: SimpleNamespace, - expected_reason: str, - expected_setup_calls: int, - expected_access_token: str, -) -> None: - """Test reauthentication flow outcomes.""" - - mock_config_entry.add_to_hass(hass) - - mock_dropbox_client.get_account_info.return_value = new_account_info - - result = await mock_config_entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == 200 - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "updated-access-token", - "token_type": "Bearer", - "expires_in": 120, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == expected_reason - assert mock_setup_entry.await_count == expected_setup_calls - - assert mock_config_entry.data["token"]["access_token"] == expected_access_token diff --git a/tests/components/dropbox/test_init.py b/tests/components/dropbox/test_init.py deleted file mode 100644 index 8d468f18727..00000000000 --- a/tests/components/dropbox/test_init.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Test the Dropbox integration setup.""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, patch - -import pytest -from python_dropbox_api import DropboxAuthException, DropboxUnknownException - -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import ( - ImplementationUnavailableError, -) - -from tests.common import MockConfigEntry - - -@pytest.mark.usefixtures("mock_dropbox_client") -async def test_setup_entry( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test successful setup of a config entry.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - - -async def test_setup_entry_auth_failed( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_dropbox_client: AsyncMock, -) -> None: - """Test setup failure when authentication fails.""" - mock_dropbox_client.get_account_info.side_effect = DropboxAuthException( - "Invalid token" - ) - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR - - -@pytest.mark.parametrize( - "side_effect", - [DropboxUnknownException("Unknown error"), TimeoutError("Connection timed out")], - ids=["unknown_exception", "timeout_error"], -) -async def test_setup_entry_not_ready( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_dropbox_client: AsyncMock, - side_effect: Exception, -) -> None: - """Test setup retry when the service is temporarily unavailable.""" - mock_dropbox_client.get_account_info.side_effect = side_effect - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_setup_entry_implementation_unavailable( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test setup retry when OAuth implementation is unavailable.""" - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.dropbox.async_get_config_entry_implementation", - side_effect=ImplementationUnavailableError, - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -@pytest.mark.usefixtures("mock_dropbox_client") -async def test_unload_entry( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test unloading a config entry.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED From 4f88c5ed29fbf89b523cf10d643ccb13ab84b5b8 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:26:03 +0200 Subject: [PATCH 127/138] Bump solarlog_cli to 0.7.1 (#166990) --- homeassistant/components/solarlog/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index b9b47dbbaa2..9b7d7eb183f 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["solarlog_cli"], "quality_scale": "platinum", - "requirements": ["solarlog_cli==0.7.0"] + "requirements": ["solarlog_cli==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 05db6cf0cc2..436826d312f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2981,7 +2981,7 @@ solaredge-local==0.2.3 solaredge-web==0.0.1 # homeassistant.components.solarlog -solarlog_cli==0.7.0 +solarlog_cli==0.7.1 # homeassistant.components.solarman solarman-opendata==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0092c94d6bb..50d863a8923 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2523,7 +2523,7 @@ soco==0.30.14 solaredge-web==0.0.1 # homeassistant.components.solarlog -solarlog_cli==0.7.0 +solarlog_cli==0.7.1 # homeassistant.components.solarman solarman-opendata==0.0.3 From 12d6d7ef885ed81abc8626774b1070d59f64fff9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 Apr 2026 08:27:06 +0200 Subject: [PATCH 128/138] Add BEGA brand (#166992) --- homeassistant/brands/bega.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/bega.json diff --git a/homeassistant/brands/bega.json b/homeassistant/brands/bega.json new file mode 100644 index 00000000000..7ff9ece9715 --- /dev/null +++ b/homeassistant/brands/bega.json @@ -0,0 +1,5 @@ +{ + "domain": "bega", + "name": "BEGA", + "iot_standards": ["zigbee"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 20aeb88c3e6..6ab998db816 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -736,6 +736,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "bega": { + "name": "BEGA", + "iot_standards": [ + "zigbee" + ] + }, "bge": { "name": "Baltimore Gas and Electric (BGE)", "integration_type": "virtual", From 689ee7c1e70390dd244b13914f889947cae6e9ea Mon Sep 17 00:00:00 2001 From: Jackson_57 <49173011+jackson-57@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:21:39 -0700 Subject: [PATCH 129/138] Bump led-ble to 1.1.8 (#166999) --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index e64ef235a9f..0fb5a3b3317 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -36,5 +36,5 @@ "documentation": "https://www.home-assistant.io/integrations/led_ble", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.28.4", "led-ble==1.1.7"] + "requirements": ["bluetooth-data-tools==1.28.4", "led-ble==1.1.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 436826d312f..6b5b6307d9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1410,7 +1410,7 @@ ld2410-ble==0.1.1 leaone-ble==0.3.0 # homeassistant.components.led_ble -led-ble==1.1.7 +led-ble==1.1.8 # homeassistant.components.lektrico lektricowifi==0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50d863a8923..d0927d00145 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1247,7 +1247,7 @@ ld2410-ble==0.1.1 leaone-ble==0.3.0 # homeassistant.components.led_ble -led-ble==1.1.7 +led-ble==1.1.8 # homeassistant.components.lektrico lektricowifi==0.1 From 3ed2dccbec1e804a8a1fb3dcfa7918b87cb54df9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:32:00 +0200 Subject: [PATCH 130/138] Update requests to 2.33.1 (#167014) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e6417021b0d..83fb2faca06 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 PyYAML==6.0.3 -requests==2.32.5 +requests==2.33.1 securetar==2026.2.0 SQLAlchemy==2.0.41 standard-aifc==3.13.0 diff --git a/pyproject.toml b/pyproject.toml index 898ce020da1..d01da44e721 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", "PyYAML==6.0.3", - "requests==2.32.5", + "requests==2.33.1", "securetar==2026.2.0", "SQLAlchemy==2.0.41", "standard-aifc==3.13.0", diff --git a/requirements.txt b/requirements.txt index 50c1319f8be..a10e4034c7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,7 @@ pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 PyYAML==6.0.3 -requests==2.32.5 +requests==2.33.1 securetar==2026.2.0 SQLAlchemy==2.0.41 standard-aifc==3.13.0 From ae60135a089ad3df60a2e2cee1524fa540b600db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 12:25:09 -1000 Subject: [PATCH 131/138] Bump aiohttp to 3.13.5 (#167015) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 83fb2faca06..2b13550e5c4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==4.0.0 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.4 +aiohttp==3.13.5 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index d01da44e721..309dda00282 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # module level in `bootstrap.py` and its requirements thus need to be in # requirements.txt to ensure they are always installed "aiogithubapi==26.0.0", - "aiohttp==3.13.4", + "aiohttp==3.13.5", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index a10e4034c7b..fca3d009ed0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ aiodns==4.0.0 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.4 +aiohttp==3.13.5 aiohttp_cors==0.8.1 aiozoneinfo==0.2.3 annotatedyaml==1.0.2 From 87e0f2d36c0b42ed0497e41a813108d45c88c6a8 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 1 Apr 2026 06:02:21 +0200 Subject: [PATCH 132/138] Bump ZHA to 1.1.1 (#167025) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 31f0d0d9e83..d36b5bedf9e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==1.1.0", "serialx==0.6.2"], + "requirements": ["zha==1.1.1", "serialx==0.6.2"], "usb": [ { "description": "*2652*", diff --git a/requirements_all.txt b/requirements_all.txt index 6b5b6307d9c..7ba1213ef82 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3383,7 +3383,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==1.1.0 +zha==1.1.1 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0927d00145..c511355575c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2865,7 +2865,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==1.1.0 +zha==1.1.1 # homeassistant.components.zinvolt zinvolt==0.3.0 From b14e729b2dab74766c934ad4b748d0d74d6090b2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Apr 2026 06:35:41 +0000 Subject: [PATCH 133/138] Bump version to 2026.4.0b9 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 10335630c37..c44582799cc 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0b9" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index 309dda00282..5c184d360a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0b8" +version = "2026.4.0b9" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 37e69cad166e380271434be0c601a33f0f852e08 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 1 Apr 2026 09:54:01 +0200 Subject: [PATCH 134/138] Store received backup in temp backup dir only (#166982) --- homeassistant/components/backup/manager.py | 7 ++- tests/components/backup/test_manager.py | 68 ++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 520ea8ea38b..a05a55bf4e9 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -12,7 +12,7 @@ import hashlib import io from itertools import chain import json -from pathlib import Path, PurePath +from pathlib import Path, PurePath, PureWindowsPath import shutil import sys import tarfile @@ -1957,7 +1957,10 @@ class CoreBackupReaderWriter(BackupReaderWriter): suggested_filename: str, ) -> WrittenBackup: """Receive a backup.""" - temp_file = Path(self.temp_backup_dir, suggested_filename) + safe_filename = PureWindowsPath(suggested_filename).name + if not safe_filename or safe_filename == "..": + safe_filename = "backup.tar" + temp_file = Path(self.temp_backup_dir, safe_filename) async_add_executor_job = self._hass.async_add_executor_job await async_add_executor_job(make_backup_dir, self.temp_backup_dir) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 2a64bcd6843..4d162f12432 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -23,6 +23,7 @@ from unittest.mock import ( patch, ) +from aiohttp import FormData from freezegun.api import FrozenDateTimeFactory import pytest from securetar import SecureTarArchive, SecureTarFile @@ -2013,6 +2014,73 @@ async def test_receive_backup( assert unlink_mock.call_count == temp_file_unlink_call_count +@pytest.mark.parametrize( + ("suggested_filename", "expected_filename"), + [ + ("backup.tar", "backup.tar"), + ("../traversal.tar", "traversal.tar"), + ("../../etc/passwd", "passwd"), + ("subdir/backup.tar", "backup.tar"), + (".", "backup.tar"), + ("..", "backup.tar"), + ("../..", "backup.tar"), + ("..\\traversal.tar", "traversal.tar"), + ("C:\\fakepath\\backup.tar", "backup.tar"), + ], +) +async def test_receive_backup_path_traversal( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + suggested_filename: str, + expected_filename: str, +) -> None: + """Test path traversal in suggested filename is prevented.""" + await setup_backup_integration(hass) + # Make sure we wait for Platform.EVENT and Platform.SENSOR to be fully processed, + # to avoid interference with the Path.open patching below which is used to verify + # that the file is written to the expected location. + await hass.async_block_till_done(True) + client = await hass_client() + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + expected_path = Path(hass.config.path("tmp_backups"), expected_filename) + opened_paths: list[Path] = [] + + def track_open(self: Path, *args: Any, **kwargs: Any) -> Any: + opened_paths.append(self) + return open_mock(self, *args, **kwargs) + + with ( + patch("pathlib.Path.open", track_open), + patch("homeassistant.components.backup.manager.make_backup_dir"), + patch("shutil.move"), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ) as read_backup_mock, + patch("pathlib.Path.unlink"), + ): + data = FormData(quote_fields=False) + data.add_field( + "file", + upload_data, + filename=suggested_filename, + content_type="application/octet-stream", + ) + resp = await client.post( + "/api/backup/upload?agent_id=backup.local", + data=data, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + # Verify all file opens went to the expected safe path + assert opened_paths == [expected_path] + # read_backup is called with the temp_file path; verify it's sanitized + read_backup_mock.assert_called_once_with(expected_path) + + async def test_receive_backup_busy_manager( hass: HomeAssistant, hass_client: ClientSessionGenerator, From c6233d02e8df814b60df48efb14c05fb2551f022 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Apr 2026 11:59:50 +0200 Subject: [PATCH 135/138] Update frontend to 20260325.5 (#167050) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a87ec738fe0..ce977e3fd61 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.4"] + "requirements": ["home-assistant-frontend==20260325.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2b13550e5c4..b6a184eb817 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.11.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.4 +home-assistant-frontend==20260325.5 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 7ba1213ef82..8acf4206775 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.4 +home-assistant-frontend==20260325.5 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c511355575c..9ca71cfb3e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.4 +home-assistant-frontend==20260325.5 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From c06d898b00ffdded4695871826e38d1345fdec95 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Apr 2026 10:23:39 +0000 Subject: [PATCH 136/138] Bump version to 2026.4.0b10 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c44582799cc..dbe388ab7bd 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b9" +PATCH_VERSION: Final = "0b10" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index 5c184d360a0..49b5e19abb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0b9" +version = "2026.4.0b10" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From c70ddd559b41b11f55778c0252b0229c40f6f10d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 1 Apr 2026 13:55:23 +0200 Subject: [PATCH 137/138] Bump aioamazondevices to 13.3.2 (#167052) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 0401bb3828e..0dcb3dd3415 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.3.1"] + "requirements": ["aioamazondevices==13.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8acf4206775..3b2e30e6451 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.3.1 +aioamazondevices==13.3.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ca71cfb3e1..0caae29d673 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.3.1 +aioamazondevices==13.3.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 803531125babee4af5bc1aeab3544f2e30cb6994 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Apr 2026 12:05:57 +0000 Subject: [PATCH 138/138] Bump version to 2026.4.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index dbe388ab7bd..bed132fef67 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b10" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index 49b5e19abb4..b83871915da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0b10" +version = "2026.4.0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3."