From 4d62e4765d49d87df1f295055d28a298bb81fd0c Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Tue, 5 May 2026 17:45:47 +0200 Subject: [PATCH 01/12] Add a number entity to set display time offset (in minutes) for Switchbot Meter CO2 devices. (#169603) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/switchbot/__init__.py | 1 + homeassistant/components/switchbot/number.py | 79 +++++++++ .../components/switchbot/strings.json | 5 + tests/components/switchbot/test_number.py | 167 ++++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 homeassistant/components/switchbot/number.py create mode 100644 tests/components/switchbot/test_number.py diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 1dcbd6a0492a02..43f36dc88e8f10 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -56,6 +56,7 @@ SupportedModels.HYGROMETER.value: [Platform.SENSOR], SupportedModels.HYGROMETER_CO2.value: [ Platform.BUTTON, + Platform.NUMBER, Platform.SENSOR, Platform.SELECT, ], diff --git a/homeassistant/components/switchbot/number.py b/homeassistant/components/switchbot/number.py new file mode 100644 index 00000000000000..f0d8b0eef3ced7 --- /dev/null +++ b/homeassistant/components/switchbot/number.py @@ -0,0 +1,79 @@ +"""Number platform for SwitchBot devices.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +import switchbot +from switchbot import SwitchbotOperationError +from switchbot.devices.meter_pro import MAX_TIME_OFFSET + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity, exception_handler + +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(days=7) +_LOGGER = logging.getLogger(__name__) +_SECONDS_IN_MINUTE = 60 +_MAX_TIME_OFFSET_MINUTES = MAX_TIME_OFFSET // _SECONDS_IN_MINUTE + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot number platform.""" + coordinator = entry.runtime_data + + if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2): + async_add_entities( + [SwitchBotMeterProCO2DisplayTimeOffsetNumber(coordinator)], True + ) + + +class SwitchBotMeterProCO2DisplayTimeOffsetNumber(SwitchbotEntity, NumberEntity): + """Number entity to set the time offset for Meter Pro CO2 devices.""" + + _device: switchbot.SwitchbotMeterProCO2 + _attr_device_class = NumberDeviceClass.DURATION + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "display_time_offset" + _attr_native_min_value = -_MAX_TIME_OFFSET_MINUTES + _attr_native_max_value = _MAX_TIME_OFFSET_MINUTES + _attr_native_step = 1.0 + _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_should_poll = True + _attr_entity_registry_enabled_default = False + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the number entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.base_unique_id}_display_time_offset" + + @exception_handler + async def async_set_native_value(self, value: float) -> None: + """Set the time offset.""" + _LOGGER.debug("Setting time offset to %s minutes for %s", value, self._address) + offset_minutes = round(value) + offset_seconds = offset_minutes * _SECONDS_IN_MINUTE + await self._device.set_time_offset(offset_seconds) + self._attr_native_value = offset_minutes + self.async_write_ha_state() + + async def async_update(self) -> None: + """Fetch the latest time offset from the device.""" + try: + offset_seconds = await self._device.get_time_offset() + except SwitchbotOperationError: + _LOGGER.debug( + "Failed to update time offset for %s", self._address, exc_info=True + ) + return + self._attr_native_value = round(offset_seconds / _SECONDS_IN_MINUTE) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index b6ec6f50831713..49f9d4ca4a8331 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -272,6 +272,11 @@ } } }, + "number": { + "display_time_offset": { + "name": "Display time offset" + } + }, "select": { "time_format": { "name": "Time format", diff --git a/tests/components/switchbot/test_number.py b/tests/components/switchbot/test_number.py new file mode 100644 index 00000000000000..14e9815ed31d94 --- /dev/null +++ b/tests/components/switchbot/test_number.py @@ -0,0 +1,167 @@ +"""Tests for the switchbot number platform.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.setup import async_setup_component + +from . import DOMAIN, WOMETERTHPC_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ("offset_seconds_on_device", "expected_state"), + [ + (0, 0), + (60, 1), + (-60, -1), + (3600, 60), + (-3600, -60), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_meter_pro_co2_display_time_offset_initial_state( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + offset_seconds_on_device: int, + expected_state: int, +) -> None: + """Test the display_time_offset entity gets the initial state from a MeterProCO2 device.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, WOMETERTHPC_SERVICE_INFO) + + entry = mock_entry_factory("hygrometer_co2") + entry.add_to_hass(hass) + + with patch( + "switchbot.SwitchbotMeterProCO2.get_time_offset", + return_value=offset_seconds_on_device, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("number.test_name_display_time_offset") + assert state is not None + assert float(state.state) == expected_state + + +@pytest.mark.parametrize( + ("time_offset", "expected_seconds_on_device"), + [ + (0, 0), + (1, 60), + (-1, -60), + (5, 300), + (-5, -300), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_meter_pro_co2_set_display_time_offset( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + time_offset: int, + expected_seconds_on_device: int, +) -> None: + """Test setting time offset on a MeterProCO2 device.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, WOMETERTHPC_SERVICE_INFO) + + entry = mock_entry_factory("hygrometer_co2") + entry.add_to_hass(hass) + + mock_get_time_offset = AsyncMock(return_value=60) + mock_set_time_offset = AsyncMock(return_value=True) + + with ( + patch( + "switchbot.SwitchbotMeterProCO2.get_time_offset", + mock_get_time_offset, + ), + patch( + "switchbot.SwitchbotMeterProCO2.set_time_offset", + mock_set_time_offset, + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.test_name_display_time_offset", + ATTR_VALUE: time_offset, + }, + blocking=True, + ) + + mock_set_time_offset.assert_awaited_once_with(expected_seconds_on_device) + + state = hass.states.get("number.test_name_display_time_offset") + assert state is not None + assert float(state.state) == time_offset + + +@pytest.mark.parametrize( + ("value"), + [ + (300000), + (-300000), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_display_time_offset_out_of_range( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + value: int, +) -> None: + """Test setting time offset with out-of-range values.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, WOMETERTHPC_SERVICE_INFO) + + entry = mock_entry_factory("hygrometer_co2") + entry.add_to_hass(hass) + + mock_get_time_offset = AsyncMock(return_value=0) + mock_set_time_offset = AsyncMock(return_value=True) + + with ( + patch( + "switchbot.SwitchbotMeterProCO2.get_time_offset", + mock_get_time_offset, + ), + patch( + "switchbot.SwitchbotMeterProCO2.set_time_offset", + mock_set_time_offset, + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises( + ServiceValidationError, + match=r"Value -?\d+\.0 for number\.test_name_display_time_offset is outside valid range", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.test_name_display_time_offset", + ATTR_VALUE: value, + }, + blocking=True, + ) + + mock_set_time_offset.assert_not_awaited() From 9286b517d341e16b471b70a6191815b3cc56f45e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 May 2026 18:42:10 +0200 Subject: [PATCH 02/12] Add ruff rule to prevent __future__ annotations (#169852) Co-authored-by: Robert Resch Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pylint/plugins/hass_logger.py | 2 -- pyproject.toml | 1 + tests/components/shelly/bluetooth/__init__.py | 2 -- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/pylint/plugins/hass_logger.py b/pylint/plugins/hass_logger.py index 6cbb72d4f783d1..ed9b59c9a7347f 100644 --- a/pylint/plugins/hass_logger.py +++ b/pylint/plugins/hass_logger.py @@ -1,7 +1,5 @@ """Plugin for logger invocations.""" -from __future__ import annotations - from astroid import nodes from pylint.checkers import BaseChecker from pylint.lint import PyLinter diff --git a/pyproject.toml b/pyproject.toml index 13de992f56441e..1c2f7f060422ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -895,6 +895,7 @@ mark-parentheses = false "async_timeout".msg = "use asyncio.timeout instead" "pytz".msg = "use zoneinfo instead" "tests".msg = "You should not import tests" +"__future__.annotations".msg = "It should not be needed because Home Assistant requires Python 3.14+" [tool.ruff.lint.isort] force-sort-within-sections = true diff --git a/tests/components/shelly/bluetooth/__init__.py b/tests/components/shelly/bluetooth/__init__.py index 14588d5762c55c..5b784871bff95c 100644 --- a/tests/components/shelly/bluetooth/__init__.py +++ b/tests/components/shelly/bluetooth/__init__.py @@ -1,3 +1 @@ """Bluetooth tests for Shelly integration.""" - -from __future__ import annotations From e3ce7fb000c4eadeb55c9a6a73645a56a1b37ba7 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Tue, 5 May 2026 12:50:17 -0400 Subject: [PATCH 03/12] Bump elkm1-lib to 2.2.15 (#169843) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/elkm1/manifest.json | 2 +- homeassistant/components/elkm1/sensor.py | 4 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 1cc39278f8e5bf..376726545139d6 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -16,5 +16,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.13"] + "requirements": ["elkm1-lib==2.2.15"] } diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 189b97a45111ff..6949a915f3c7c2 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -199,7 +199,9 @@ class ElkSetting(ElkSensor): _element: Setting def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: - self._attr_native_value = self._element.value + self._attr_native_value = ( + None if self._element.value is None else str(self._element.value) + ) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/requirements_all.txt b/requirements_all.txt index 708ccd04d4f6e2..21efcf772e5944 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -892,7 +892,7 @@ elgato==5.1.2 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.13 +elkm1-lib==2.2.15 # homeassistant.components.inels elkoep-aio-mqtt==0.1.0b4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4c7c7e8ba5e84..e8a3024c1189cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -792,7 +792,7 @@ elevenlabs==2.3.0 elgato==5.1.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.13 +elkm1-lib==2.2.15 # homeassistant.components.inels elkoep-aio-mqtt==0.1.0b4 From ae3bd54ca7569d54f6bb06424be1aa3fdc505d09 Mon Sep 17 00:00:00 2001 From: Crocmagnon Date: Tue, 5 May 2026 19:40:27 +0200 Subject: [PATCH 04/12] switchbot: remove unwanted future annotations import preventing build on all new PRs (#169863) --- homeassistant/components/switchbot/number.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/switchbot/number.py b/homeassistant/components/switchbot/number.py index f0d8b0eef3ced7..baf57024d473fc 100644 --- a/homeassistant/components/switchbot/number.py +++ b/homeassistant/components/switchbot/number.py @@ -1,7 +1,5 @@ """Number platform for SwitchBot devices.""" -from __future__ import annotations - from datetime import timedelta import logging From 7430366d9b6a43a13e6bbf29fa7dd5e4909ad0b1 Mon Sep 17 00:00:00 2001 From: Freekers <1370857+Freekers@users.noreply.github.com> Date: Tue, 5 May 2026 19:47:52 +0200 Subject: [PATCH 05/12] Enable web search support for gpt-5-nano (#169710) --- homeassistant/components/openai_conversation/const.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index d314e1d4006b25..11fd3b69e140df 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -72,7 +72,6 @@ ] UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [ - "gpt-5-nano", "gpt-3.5", "gpt-4-turbo", "gpt-4.1-nano", From dc4210595ffa49edafdff7c1840f5b90cf3ca82f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 May 2026 20:49:15 +0200 Subject: [PATCH 06/12] Fix flaky test_set_scan_interval_via_platform (#169856) Co-authored-by: Claude Opus 4.7 (1M context) --- tests/helpers/test_entity_platform.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 8a2bf940ab12c1..b76189d9ffa42f 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -245,8 +245,13 @@ def platform_setup( await component.async_setup({DOMAIN: {"platform": "platform"}}) await hass.async_block_till_done() - assert mock_track.called - assert mock_track.call_args[0][0] == 30.0 + poll_calls = [ + call + for call in mock_track.call_args_list + if getattr(call.args[1], "__name__", None) == "_async_handle_interval_callback" + ] + assert len(poll_calls) == 1 + assert poll_calls[0].args[0] == 30.0 async def test_adding_entities_with_generator_and_thread_callback( From 7d7c47b56e1662fd90f4597db3f883403a407ff7 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 5 May 2026 15:06:30 -0400 Subject: [PATCH 07/12] Bump serialx to 1.7.0 (#169867) --- homeassistant/components/acer_projector/manifest.json | 2 +- homeassistant/components/serial/manifest.json | 2 +- homeassistant/components/usb/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/acer_projector/manifest.json b/homeassistant/components/acer_projector/manifest.json index 90b2e3fd153728..480a72bd09ece9 100644 --- a/homeassistant/components/acer_projector/manifest.json +++ b/homeassistant/components/acer_projector/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/acer_projector", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["serialx==1.4.1"] + "requirements": ["serialx==1.7.0"] } diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json index 380b6905016981..b7296c584c96b8 100644 --- a/homeassistant/components/serial/manifest.json +++ b/homeassistant/components/serial/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/serial", "iot_class": "local_polling", - "requirements": ["serialx==1.4.1"] + "requirements": ["serialx==1.7.0"] } diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 966275da3d9efa..e2f6c3db62bc64 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiousbwatcher==1.1.2", "serialx==1.4.1"] + "requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3d31303977d179..a632826c2797df 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -63,7 +63,7 @@ PyTurboJPEG==1.8.3 PyYAML==6.0.3 requests==2.33.1 securetar==2026.4.1 -serialx==1.4.1 +serialx==1.7.0 SQLAlchemy==2.0.49 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 21efcf772e5944..3a9e16846929b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2951,7 +2951,7 @@ sentry-sdk==2.48.0 # homeassistant.components.acer_projector # homeassistant.components.serial # homeassistant.components.usb -serialx==1.4.1 +serialx==1.7.0 # homeassistant.components.sfr_box sfrbox-api==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8a3024c1189cc..1cdd33a2c9cf45 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2517,7 +2517,7 @@ sentry-sdk==2.48.0 # homeassistant.components.acer_projector # homeassistant.components.serial # homeassistant.components.usb -serialx==1.4.1 +serialx==1.7.0 # homeassistant.components.sfr_box sfrbox-api==0.1.1 From 11ee05874a21532d72f9bead166e2191955c4c0f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 May 2026 22:11:08 +0200 Subject: [PATCH 08/12] Improve trigger test helper docstrings (#169869) --- tests/components/common.py | 219 ++++++++++++++++++++++++++++++------- 1 file changed, 182 insertions(+), 37 deletions(-) diff --git a/tests/components/common.py b/tests/components/common.py index 809a677b0d39da..e8cb0035e13f24 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -397,10 +397,28 @@ def parametrize_trigger_states( trigger_from_none: bool = True, retrigger_on_target_state: bool = False, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: - """Parametrize states and expected service call counts. + """Parametrize sequences of states and expected service call counts. - The target_states, other_states, and extra_invalid_states iterables are - either iterables of states or iterables of (state, attributes) tuples. + Returns a list of `(trigger, trigger_options, states)` tuples, where + `states` is a list of TriggerStateDescription dicts describing the state + sequence to drive the trigger through. + + The target_states, other_states, and extra_invalid_states + iterables are either iterables of states or iterables of (state, attributes) + tuples. + + `target_states` are states that should fire the trigger. + + `other_states` are states that should NOT fire the trigger and that DO + count toward the all/count check (i.e. an entity in such a state blocks + behavior=last). + + `extra_invalid_states` are *additional* states (on top of the always- + included STATE_UNAVAILABLE and STATE_UNKNOWN) that should be treated as + invalid by the trigger (i.e. `is_valid_transition` rejects transitions + out of them). They drive the "transition from other state to invalid" + and "initial state invalid" patterns alongside the built-in + unavailable/unknown states. Set `trigger_from_none` to False if the trigger is not expected to fire when the initial state is None, this is relevant for triggers that limit @@ -409,46 +427,71 @@ def parametrize_trigger_states( Set `retrigger_on_target_state` to True if the trigger is expected to fire when the state changes to another target state. - - Returns a list of tuples with (trigger, list of states), - where states is a list of TriggerStateDescription dicts. """ - extra_invalid_states = extra_invalid_states or [] - invalid_states = [STATE_UNAVAILABLE, STATE_UNKNOWN, *extra_invalid_states] + invalid_states = [ + STATE_UNAVAILABLE, + STATE_UNKNOWN, + *(extra_invalid_states or []), + ] required_filter_attributes = required_filter_attributes or {} trigger_options = trigger_options or {} - def state_with_attributes( - state: str | None | tuple[str | None, dict], count: int - ) -> TriggerStateDescription: - """Return TriggerStateDescription dict.""" + def _included_state_desc( + state: str | None | tuple[str | None, dict], + ) -> StateDescription: + """Build a state for entities meant to match the trigger's target. + + The required_filter_attributes are merged in so the state passes the + trigger's filter. + """ + if isinstance(state, str) or state is None: + return {"state": state, "attributes": required_filter_attributes} + return { + "state": state[0], + "attributes": state[1] | required_filter_attributes, + } + + def _excluded_state_desc( + state: str | None | tuple[str | None, dict], + ) -> StateDescription: + """Build a state for entities outside the trigger's target. + + The required_filter_attributes are intentionally NOT merged in so the + state fails the trigger's filter. When the trigger has no filter, the + excluded entity is fully irrelevant: its state value is set to None. + """ if isinstance(state, str) or state is None: return { - "included_state": { - "state": state, - "attributes": required_filter_attributes, - }, - "excluded_state": { - "state": state if required_filter_attributes else None, - "attributes": {}, - }, - "count": count, + "state": state if required_filter_attributes else None, + "attributes": {}, } return { - "included_state": { - "state": state[0], - "attributes": state[1] | required_filter_attributes, - }, - "excluded_state": { - "state": state[0] if required_filter_attributes else None, - "attributes": state[1], - }, + "state": state[0] if required_filter_attributes else None, + "attributes": state[1], + } + + def state_with_attributes( + state: str | None | tuple[str | None, dict], + count: int, + ) -> TriggerStateDescription: + """Return TriggerStateDescription dict.""" + return { + "included_state": _included_state_desc(state), + "excluded_state": _excluded_state_desc(state), "count": count, } tests = [ - # Initial state None + # Pattern: entities start unset (state=None / removed) and approach + # a target state via an "other" intermediate. + # Sequence per (target, other) pair: + # None -> target (0) -> other (0) -> target (1 or 0). + # The first (target, 0) verifies that arming-from-None does not fire + # on its own. The transition to `other` lets the trigger relax. The + # final transition to `target` should fire — count is 1 by default, + # but 0 when the trigger cannot fire from a None initial state (see + # `trigger_from_none`). ( trigger, trigger_options, @@ -467,7 +510,12 @@ def state_with_attributes( ) ), ), - # Initial state different from target state + # Pattern: entities start in a non-target "other" state and toggle + # back and forth to a target state. + # Sequence per (target, other) pair: + # other -> target (1) -> other (0) -> target (1). + # Verifies the trigger fires on each fresh other -> target + # transition and does not fire on the reverse target -> other. ( trigger, trigger_options, @@ -484,7 +532,15 @@ def state_with_attributes( ) ), ), - # Initial state same as target state + # Pattern: entities start *already* in the target state — the + # trigger should not fire just because we arm against an already- + # matching state — and we then exercise re-entry. + # Sequence per (target, other) pair: + # target -> target (0, no-op) + # -> other (0) + # -> target (1, fires on fresh other -> target) + # -> target (0, repeated target should not retrigger) + # -> unavailable (0). ( trigger, trigger_options, @@ -504,7 +560,12 @@ def state_with_attributes( ) ), ), - # Transition from other state to unavailable / unknown + # Pattern: an "other" -> "invalid" -> "other" round-trip should not + # arm the trigger; only the subsequent other -> target transition + # fires. Iterates `invalid_states` so unavailable/unknown plus any + # caller-supplied extra invalids are all covered. + # Sequence per (invalid, target, other): + # other -> invalid (0) -> other (0) -> target (1). ( trigger, trigger_options, @@ -522,7 +583,14 @@ def state_with_attributes( ) ), ), - # Initial state unavailable / unknown + extra invalid states + # Pattern: entities start in an invalid state and recover. Mirrors + # the previous pattern but with the invalid state as the *initial* + # condition (so no transition out of it has occurred yet at arm + # time). Iterates `invalid_states`. + # Sequence per (invalid, target, other): + # invalid -> target (0) -> other (0) -> target (1). + # The first target hop is 0 because the trigger doesn't fire when + # arming-from-invalid is the very first transition. ( trigger, trigger_options, @@ -543,7 +611,20 @@ def state_with_attributes( ] if len(target_states) > 1: - # If more than one target state, test state change between target states + # Pattern: transitions *between* distinct target states. For each + # adjacent pair `(prev_target, target)` we verify that: + # - prev_target -> target either retriggers or not, depending on + # `retrigger_on_target_state`, + # - target -> other -> prev_target retriggers, + # - prev_target -> target again obeys the retrigger flag, + # - and a trailing target -> unavailable does not fire. + # Sequence per (prev_target, target, other): + # prev_target + # -> target (1 if retrigger_on_target_state else 0) + # -> other (0) + # -> prev_target (1) + # -> target (1 if retrigger_on_target_state else 0) + # -> unavailable (0). tests.append( ( trigger, @@ -597,7 +678,38 @@ def parametrize_numerical_attribute_changed_trigger_states( required_filter_attributes: dict | None = None, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: - """Parametrize states and expected service call counts for numerical changed triggers.""" + """Parametrize states and expected service call counts for numerical-changed triggers. + + Generates state sequences for a trigger that fires whenever an attribute + crosses or matches a "changed" threshold (modes "any" / "above" / "below"). + The trigger is exercised across three threshold types in turn; for each, + the helper invokes `parametrize_trigger_states` with target/other/excluded + states populated from the supplied `attribute` values. Threshold values + are fixed at 10 and 90 (interpreted in the trigger's threshold unit). + + Returns a list of `(trigger, trigger_options, states)` tuples — the same + shape as `parametrize_trigger_states`, suitable for splatting into a + `pytest.mark.parametrize` over `("trigger", "trigger_options", "states")`. + + Args: + trigger: Trigger key, e.g. `"climate.target_humidity_changed"`. + state: The `state.state` value to use for entities meant to match the + trigger (the attribute lives on top of this state). + attribute: Name of the attribute the trigger reads. The helper + generates target/other/excluded states by varying this attribute. + threshold_unit: When set, the threshold values in `trigger_options` + get this unit attached (`unit_of_measurement`). Defaults to + UNDEFINED, meaning no unit is added. + trigger_options: Extra keys merged into the generated `options` dict + for each threshold-type variant. + required_filter_attributes: Attributes that must be present on the + entity for the trigger's domain filter to accept it (forwarded to + `parametrize_trigger_states`). Use this for triggers gated by + `device_class` or similar. + unit_attributes: Attributes (typically `{ATTR_UNIT_OF_MEASUREMENT: ...}`) + merged into every generated state, so the entity carries a unit + alongside its tracked attribute. + """ trigger_options = trigger_options or {} unit_attributes = unit_attributes or {} @@ -681,7 +793,40 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( required_filter_attributes: dict | None = None, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: - """Parametrize states and expected service call counts for numerical crossed threshold triggers.""" + """Parametrize states and expected service call counts for numerical crossed-threshold triggers. + + Generates state sequences for a trigger that fires when an attribute + crosses a threshold boundary. The trigger is exercised across four + threshold types in turn — "between", "outside", "above", and "below" — + and for each, the helper invokes `parametrize_trigger_states` with + target/other/excluded states populated from the supplied `attribute` + values. Threshold values are fixed at 10 and 90 (or the pair (10, 90) for + range modes), interpreted in the trigger's threshold unit. + + Returns a list of `(trigger, trigger_options, states)` tuples — the same + shape as `parametrize_trigger_states`, suitable for splatting into a + `pytest.mark.parametrize` over `("trigger", "trigger_options", "states")`. + + Args: + trigger: Trigger key, e.g. + `"climate.target_humidity_crossed_threshold"`. + state: The `state.state` value to use for entities meant to match the + trigger (the attribute lives on top of this state). + attribute: Name of the attribute the trigger reads. The helper + generates target/other/excluded states by varying this attribute. + threshold_unit: When set, the threshold values in `trigger_options` + get this unit attached (`unit_of_measurement`). Defaults to + UNDEFINED, meaning no unit is added. + trigger_options: Extra keys merged into the generated `options` dict + for each threshold-type variant. + required_filter_attributes: Attributes that must be present on the + entity for the trigger's domain filter to accept it (forwarded to + `parametrize_trigger_states`). Use this for triggers gated by + `device_class` or similar. + unit_attributes: Attributes (typically `{ATTR_UNIT_OF_MEASUREMENT: ...}`) + merged into every generated state, so the entity carries a unit + alongside its tracked attribute. + """ trigger_options = trigger_options or {} unit_attributes = unit_attributes or {} From c81c1cbb141b2ea108db99ba88973cadf75dab64 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 5 May 2026 16:18:46 -0400 Subject: [PATCH 09/12] Remove legacy weather template entities (#169734) --- homeassistant/components/template/weather.py | 49 +- .../template/snapshots/test_weather.ambr | 85 +- tests/components/template/test_helpers.py | 936 +----------------- tests/components/template/test_weather.py | 121 +-- 4 files changed, 69 insertions(+), 1122 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 7b5f14c35ed3b8..4ae865cda77ca0 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -25,19 +25,12 @@ ATTR_CONDITION_WINDY_VARIANT, DOMAIN as WEATHER_DOMAIN, ENTITY_ID_FORMAT, - PLATFORM_SCHEMA as WEATHER_PLATFORM_SCHEMA, Forecast, WeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_NAME, - CONF_TEMPERATURE_UNIT, - CONF_UNIQUE_ID, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -230,18 +223,6 @@ make_template_entity_common_modern_schema(WEATHER_DOMAIN, DEFAULT_NAME).schema ) -PLATFORM_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(WEATHER_COMMON_LEGACY_SCHEMA.schema) - .extend(WEATHER_PLATFORM_SCHEMA.schema) -) - - WEATHER_CONFIG_ENTRY_SCHEMA = WEATHER_COMMON_MODERN_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) @@ -257,21 +238,23 @@ async def async_setup_platform( # Rewrite the configuration options to modern keys. if discovery_info is None: - # Legacy - config = rewrite_legacy_to_modern_config(hass, config, LEGACY_FIELDS) - else: - # Modern and Trigger - entity_configs: list[ConfigType] = discovery_info["entities"] - modified_entity_configs = [] - for entity_config in entity_configs: - entity_config = rewrite_legacy_to_modern_config( - hass, entity_config, LEGACY_FIELDS - ) + _LOGGER.warning( + "Template weather entities can only be configured under template:" + ) + return + + # Modern and Trigger + entity_configs: list[ConfigType] = discovery_info["entities"] + modified_entity_configs = [] + for entity_config in entity_configs: + entity_config = rewrite_legacy_to_modern_config( + hass, entity_config, LEGACY_FIELDS + ) - modified_entity_configs.append(entity_config) + modified_entity_configs.append(entity_config) - if modified_entity_configs: - discovery_info["entities"] = modified_entity_configs + if modified_entity_configs: + discovery_info["entities"] = modified_entity_configs await async_setup_template_platform( hass, diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr index f50234982aeeba..77902121917e88 100644 --- a/tests/components/template/snapshots/test_weather.ambr +++ b/tests/components/template/snapshots/test_weather.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_forecasts[ConfigurationStyle.LEGACY-config0] +# name: test_forecasts[config0-ConfigurationStyle.MODERN] dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -12,7 +12,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.LEGACY-config0].1 +# name: test_forecasts[config0-ConfigurationStyle.MODERN].1 dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -25,7 +25,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.LEGACY-config0].2 +# name: test_forecasts[config0-ConfigurationStyle.MODERN].2 dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -39,7 +39,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.LEGACY-config0].3 +# name: test_forecasts[config0-ConfigurationStyle.MODERN].3 dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -52,7 +52,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.MODERN-config1] +# name: test_forecasts[config0-ConfigurationStyle.TRIGGER] dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -65,7 +65,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.MODERN-config1].1 +# name: test_forecasts[config0-ConfigurationStyle.TRIGGER].1 dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -78,7 +78,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.MODERN-config1].2 +# name: test_forecasts[config0-ConfigurationStyle.TRIGGER].2 dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -92,7 +92,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.MODERN-config1].3 +# name: test_forecasts[config0-ConfigurationStyle.TRIGGER].3 dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -105,7 +105,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.MODERN-config3] +# name: test_forecasts[config1-ConfigurationStyle.MODERN] dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -118,7 +118,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.MODERN-config3].1 +# name: test_forecasts[config1-ConfigurationStyle.MODERN].1 dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -131,7 +131,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.MODERN-config3].2 +# name: test_forecasts[config1-ConfigurationStyle.MODERN].2 dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -145,7 +145,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.MODERN-config3].3 +# name: test_forecasts[config1-ConfigurationStyle.MODERN].3 dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -158,7 +158,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.TRIGGER-config2] +# name: test_forecasts[config1-ConfigurationStyle.TRIGGER] dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -171,7 +171,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.TRIGGER-config2].1 +# name: test_forecasts[config1-ConfigurationStyle.TRIGGER].1 dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -184,7 +184,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.TRIGGER-config2].2 +# name: test_forecasts[config1-ConfigurationStyle.TRIGGER].2 dict({ 'weather.template_weather': dict({ 'forecast': list([ @@ -198,60 +198,7 @@ }), }) # --- -# name: test_forecasts[ConfigurationStyle.TRIGGER-config2].3 - dict({ - 'weather.template_weather': dict({ - 'forecast': list([ - dict({ - 'condition': 'cloudy', - 'datetime': '2023-02-17T14:00:00+00:00', - 'temperature': 16.9, - }), - ]), - }), - }) -# --- -# name: test_forecasts[ConfigurationStyle.TRIGGER-config4] - dict({ - 'weather.template_weather': dict({ - 'forecast': list([ - dict({ - 'condition': 'cloudy', - 'datetime': '2023-02-17T14:00:00+00:00', - 'temperature': 14.2, - }), - ]), - }), - }) -# --- -# name: test_forecasts[ConfigurationStyle.TRIGGER-config4].1 - dict({ - 'weather.template_weather': dict({ - 'forecast': list([ - dict({ - 'condition': 'cloudy', - 'datetime': '2023-02-17T14:00:00+00:00', - 'temperature': 14.2, - }), - ]), - }), - }) -# --- -# name: test_forecasts[ConfigurationStyle.TRIGGER-config4].2 - dict({ - 'weather.template_weather': dict({ - 'forecast': list([ - dict({ - 'condition': 'fog', - 'datetime': '2023-02-17T14:00:00+00:00', - 'is_daytime': True, - 'temperature': 14.2, - }), - ]), - }), - }) -# --- -# name: test_forecasts[ConfigurationStyle.TRIGGER-config4].3 +# name: test_forecasts[config1-ConfigurationStyle.TRIGGER].3 dict({ 'weather.template_weather': dict({ 'forecast': list([ diff --git a/tests/components/template/test_helpers.py b/tests/components/template/test_helpers.py index 99b995eb927550..a9103ba1444c24 100644 --- a/tests/components/template/test_helpers.py +++ b/tests/components/template/test_helpers.py @@ -7,50 +7,24 @@ from homeassistant.components.device_automation import toggle_entity from homeassistant.components.template.alarm_control_panel import ( - LEGACY_FIELDS as ALARM_CONTROL_PANEL_LEGACY_FIELDS, SCRIPT_FIELDS as ALARM_CONTROL_PANEL_SCRIPT_FIELDS, ) -from homeassistant.components.template.binary_sensor import ( - LEGACY_FIELDS as BINARY_SENSOR_LEGACY_FIELDS, -) from homeassistant.components.template.button import ( SCRIPT_FIELDS as BUTTON_SCRIPT_FIELDS, StateButtonEntity, ) -from homeassistant.components.template.cover import ( - LEGACY_FIELDS as COVER_LEGACY_FIELDS, - SCRIPT_FIELDS as COVER_SCRIPT_FIELDS, -) -from homeassistant.components.template.fan import ( - LEGACY_FIELDS as FAN_LEGACY_FIELDS, - SCRIPT_FIELDS as FAN_SCRIPT_FIELDS, -) -from homeassistant.components.template.helpers import ( - async_setup_template_platform, - create_legacy_template_issue, - format_migration_config, - rewrite_legacy_to_modern_config, - rewrite_legacy_to_modern_configs, -) -from homeassistant.components.template.light import ( - LEGACY_FIELDS as LIGHT_LEGACY_FIELDS, - SCRIPT_FIELDS as LIGHT_SCRIPT_FIELDS, -) -from homeassistant.components.template.lock import ( - LEGACY_FIELDS as LOCK_LEGACY_FIELDS, - SCRIPT_FIELDS as LOCK_SCRIPT_FIELDS, -) +from homeassistant.components.template.cover import SCRIPT_FIELDS as COVER_SCRIPT_FIELDS +from homeassistant.components.template.fan import SCRIPT_FIELDS as FAN_SCRIPT_FIELDS +from homeassistant.components.template.helpers import async_setup_template_platform +from homeassistant.components.template.light import SCRIPT_FIELDS as LIGHT_SCRIPT_FIELDS +from homeassistant.components.template.lock import SCRIPT_FIELDS as LOCK_SCRIPT_FIELDS from homeassistant.components.template.number import ( SCRIPT_FIELDS as NUMBER_SCRIPT_FIELDS, ) from homeassistant.components.template.select import ( SCRIPT_FIELDS as SELECT_SCRIPT_FIELDS, ) -from homeassistant.components.template.sensor import ( - LEGACY_FIELDS as SENSOR_LEGACY_FIELDS, -) from homeassistant.components.template.switch import ( - LEGACY_FIELDS as SWITCH_LEGACY_FIELDS, SCRIPT_FIELDS as SWITCH_SCRIPT_FIELDS, ) from homeassistant.components.template.update import ( @@ -58,19 +32,12 @@ ) from homeassistant.components.template.vacuum import ( CONF_CLEAN_SEGMENTS as VACUUM_CLEAN_SEGMENTS, - LEGACY_FIELDS as VACUUM_LEGACY_FIELDS, SCRIPT_FIELDS as VACUUM_SCRIPT_FIELDS, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) -from homeassistant.helpers.template import Template +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component from .conftest import ( ConfigurationStyle, @@ -84,297 +51,6 @@ from tests.common import MockConfigEntry, mock_platform -@pytest.mark.parametrize( - ("legacy_fields", "old_attr", "new_attr", "attr_template"), - [ - ( - LOCK_LEGACY_FIELDS, - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - LOCK_LEGACY_FIELDS, - "code_format_template", - "code_format", - "{{ 'some format' }}", - ), - ], -) -async def test_legacy_to_modern_config( - hass: HomeAssistant, - legacy_fields, - old_attr: str, - new_attr: str, - attr_template: str, -) -> None: - """Test the conversion of single legacy template to modern template.""" - config = { - "friendly_name": "foo bar", - "unique_id": "foo-bar-entity", - "icon_template": "{{ 'mdi.abc' }}", - "entity_picture_template": "{{ 'mypicture.jpg' }}", - "availability_template": "{{ 1 == 1 }}", - old_attr: attr_template, - } - altered_configs = rewrite_legacy_to_modern_config(hass, config, legacy_fields) - - assert { - "availability": Template("{{ 1 == 1 }}", hass), - "icon": Template("{{ 'mdi.abc' }}", hass), - "name": Template("foo bar", hass), - "picture": Template("{{ 'mypicture.jpg' }}", hass), - "unique_id": "foo-bar-entity", - new_attr: Template(attr_template, hass), - } == altered_configs - - -@pytest.mark.parametrize( - ("domain", "legacy_fields", "old_attr", "new_attr", "attr_template"), - [ - ( - "alarm_control_panel", - ALARM_CONTROL_PANEL_LEGACY_FIELDS, - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "binary_sensor", - BINARY_SENSOR_LEGACY_FIELDS, - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "cover", - COVER_LEGACY_FIELDS, - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "cover", - COVER_LEGACY_FIELDS, - "position_template", - "position", - "{{ 100 }}", - ), - ( - "cover", - COVER_LEGACY_FIELDS, - "tilt_template", - "tilt", - "{{ 100 }}", - ), - ( - "fan", - FAN_LEGACY_FIELDS, - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "fan", - FAN_LEGACY_FIELDS, - "direction_template", - "direction", - "{{ 1 == 1 }}", - ), - ( - "fan", - FAN_LEGACY_FIELDS, - "oscillating_template", - "oscillating", - "{{ True }}", - ), - ( - "fan", - FAN_LEGACY_FIELDS, - "percentage_template", - "percentage", - "{{ 100 }}", - ), - ( - "fan", - FAN_LEGACY_FIELDS, - "preset_mode_template", - "preset_mode", - "{{ 'foo' }}", - ), - ( - "fan", - LIGHT_LEGACY_FIELDS, - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "rgb_template", - "rgb", - "{{ (255,255,255) }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "rgbw_template", - "rgbw", - "{{ (255,255,255,255) }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "rgbww_template", - "rgbww", - "{{ (255,255,255,255,255) }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "effect_list_template", - "effect_list", - "{{ ['a', 'b'] }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "effect_template", - "effect", - "{{ 'a' }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "level_template", - "level", - "{{ 255 }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "max_mireds_template", - "max_mireds", - "{{ 255 }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "min_mireds_template", - "min_mireds", - "{{ 255 }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "supports_transition_template", - "supports_transition", - "{{ True }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "temperature_template", - "temperature", - "{{ 255 }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "white_value_template", - "white_value", - "{{ 255 }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "hs_template", - "hs", - "{{ (255, 255) }}", - ), - ( - "light", - LIGHT_LEGACY_FIELDS, - "color_template", - "hs", - "{{ (255, 255) }}", - ), - ( - "sensor", - SENSOR_LEGACY_FIELDS, - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "sensor", - SWITCH_LEGACY_FIELDS, - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "vacuum", - VACUUM_LEGACY_FIELDS, - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "vacuum", - VACUUM_LEGACY_FIELDS, - "battery_level_template", - "battery_level", - "{{ 100 }}", - ), - ( - "vacuum", - VACUUM_LEGACY_FIELDS, - "fan_speed_template", - "fan_speed", - "{{ 7 }}", - ), - ], -) -async def test_legacy_to_modern_configs( - hass: HomeAssistant, - domain: str, - legacy_fields, - old_attr: str, - new_attr: str, - attr_template: str, -) -> None: - """Test the conversion of legacy template to modern template.""" - config = { - "foo": { - "friendly_name": "foo bar", - "unique_id": "foo-bar-entity", - "icon_template": "{{ 'mdi.abc' }}", - "entity_picture_template": "{{ 'mypicture.jpg' }}", - "availability_template": "{{ 1 == 1 }}", - old_attr: attr_template, - } - } - altered_configs = rewrite_legacy_to_modern_configs( - hass, domain, config, legacy_fields - ) - - assert len(altered_configs) == 1 - - assert [ - { - "availability": Template("{{ 1 == 1 }}", hass), - "icon": Template("{{ 'mdi.abc' }}", hass), - "name": Template("foo bar", hass), - "default_entity_id": f"{domain}.foo", - "picture": Template("{{ 'mypicture.jpg' }}", hass), - "unique_id": "foo-bar-entity", - new_attr: Template(attr_template, hass), - } - ] == altered_configs - - async def _setup_mock_devices( hass: HomeAssistant, domain: str, @@ -863,46 +539,6 @@ async def test_config_entry_device_actions( assert_action(platform_setup, calls, call_count + 1, "fake_action") -@pytest.mark.parametrize( - ("domain", "legacy_fields"), - [ - ("binary_sensor", BINARY_SENSOR_LEGACY_FIELDS), - ("sensor", SENSOR_LEGACY_FIELDS), - ], -) -async def test_friendly_name_template_legacy_to_modern_configs( - hass: HomeAssistant, - domain: str, - legacy_fields, -) -> None: - """Test the conversion of friendly_name_tempalte in legacy template to modern template.""" - config = { - "foo": { - "unique_id": "foo-bar-entity", - "icon_template": "{{ 'mdi.abc' }}", - "entity_picture_template": "{{ 'mypicture.jpg' }}", - "availability_template": "{{ 1 == 1 }}", - "friendly_name_template": "{{ 'foo bar' }}", - } - } - altered_configs = rewrite_legacy_to_modern_configs( - hass, domain, config, legacy_fields - ) - - assert len(altered_configs) == 1 - - assert [ - { - "availability": Template("{{ 1 == 1 }}", hass), - "icon": Template("{{ 'mdi.abc' }}", hass), - "default_entity_id": f"{domain}.foo", - "picture": Template("{{ 'mypicture.jpg' }}", hass), - "unique_id": "foo-bar-entity", - "name": Template("{{ 'foo bar' }}", hass), - } - ] == altered_configs - - async def test_platform_not_ready( hass: HomeAssistant, ) -> None: @@ -917,563 +553,3 @@ async def test_platform_not_ready( None, {"coordinator": None, "entities": []}, ) - - -@pytest.mark.parametrize( - ("domain", "config", "breadcrumb"), - [ - ( - "template", - { - "template": [ - { - "sensors": { - "undocumented_configuration": { - "value_template": "{{ 'armed_away' }}", - } - } - }, - ] - }, - "undocumented_configuration", - ), - ( - "template", - { - "template": [ - { - "binary_sensors": { - "undocumented_configuration": { - "value_template": "{{ 'armed_away' }}", - } - } - }, - ] - }, - "undocumented_configuration", - ), - ( - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "safe_alarm_panel": { - "value_template": "{{ 'armed_away' }}", - } - }, - }, - }, - "safe_alarm_panel", - ), - ( - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "sun_up": { - "value_template": "{{ state_attr('sun.sun', 'elevation') > 0 }}", - } - }, - }, - }, - "sun_up", - ), - ( - "cover", - { - "cover": { - "platform": "template", - "covers": { - "garage_door": { - "value_template": "{{ states('sensor.garage_door')|float > 0 }}", - "open_cover": {"action": "script.toggle"}, - "close_cover": {"action": "script.toggle"}, - } - }, - }, - }, - "garage_door", - ), - ( - "fan", - { - "fan": { - "platform": "template", - "fans": { - "bedroom_fan": { - "value_template": "{{ states('input_boolean.state') }}", - "turn_on": {"action": "script.toggle"}, - "turn_off": {"action": "script.toggle"}, - } - }, - }, - }, - "bedroom_fan", - ), - ( - "light", - { - "light": { - "platform": "template", - "lights": { - "theater_lights": { - "value_template": "{{ states('input_boolean.state') }}", - "turn_on": {"action": "script.toggle"}, - "turn_off": {"action": "script.toggle"}, - } - }, - }, - }, - "theater_lights", - ), - ( - "lock", - { - "lock": { - "platform": "template", - "value_template": "{{ states('input_boolean.state') }}", - "lock": {"action": "script.toggle"}, - "unlock": {"action": "script.toggle"}, - }, - }, - "Template Entity", - ), - ( - "sensor", - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "It {{ states.sensor.test_state.state }}.", - "attribute_templates": {"something": "{{ 'bar' }}"}, - } - }, - }, - }, - "test_template_sensor", - ), - ( - "switch", - { - "switch": { - "platform": "template", - "switches": { - "skylight": { - "value_template": "{{ is_state('sensor.skylight', 'on') }}", - "turn_on": {"action": "script.toggle"}, - "turn_off": {"action": "script.toggle"}, - } - }, - }, - }, - "skylight", - ), - ( - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "living_room_vacuum": { - "start": {"action": "script.start"}, - "attribute_templates": {"something": "{{ 'bar' }}"}, - } - }, - }, - }, - "living_room_vacuum", - ), - ( - "weather", - { - "weather": { - "platform": "template", - "name": "My Weather Station", - "unique_id": "Foobar", - "condition_template": "{{ 'rainy' }}", - "temperature_template": "{{ 20 }}", - "humidity_template": "{{ 50 }}", - }, - }, - "unique_id: Foobar", - ), - ( - "weather", - { - "weather": { - "platform": "template", - "name": "My Weather Station", - "condition_template": "{{ 'rainy' }}", - "temperature_template": "{{ 20 }}", - "humidity_template": "{{ 50 }}", - }, - }, - "My Weather Station", - ), - ], -) -async def test_legacy_deprecation( - hass: HomeAssistant, - domain: str, - config: dict, - breadcrumb: str, - issue_registry: ir.IssueRegistry, -) -> None: - """Test legacy configuration raises issue.""" - - await async_setup_component(hass, domain, config) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = next(iter(issue_registry.issues.values())) - - assert issue.domain == "template" - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.translation_placeholders["breadcrumb"] == breadcrumb - assert "platform: template" not in issue.translation_placeholders["config"] - - -@pytest.mark.parametrize( - ("domain", "config", "strings_to_check"), - [ - ( - "light", - { - "light": { - "platform": "template", - "lights": { - "garage_light_template": { - "friendly_name": "Garage Light Template", - "min_mireds_template": 153, - "max_mireds_template": 500, - "turn_on": [], - "turn_off": [], - "set_temperature": [], - "set_hs": [], - "set_level": [], - } - }, - }, - }, - [ - "turn_on: []", - "turn_off: []", - "set_temperature: []", - "set_hs: []", - "set_level: []", - ], - ), - ( - "switch", - { - "switch": { - "platform": "template", - "switches": { - "my_switch": { - "friendly_name": "Switch Template", - "turn_on": [], - "turn_off": [], - } - }, - }, - }, - [ - "turn_on: []", - "turn_off: []", - ], - ), - ( - "light", - { - "light": [ - { - "platform": "template", - "lights": { - "atrium_lichterkette": { - "unique_id": "atrium_lichterkette", - "friendly_name": "Atrium Lichterkette", - "value_template": "{{ states('input_boolean.atrium_lichterkette_power') }}", - "level_template": "{% if is_state('input_boolean.atrium_lichterkette_power', 'off') %}\n 0\n{% else %}\n {{ states('input_number.atrium_lichterkette_brightness') | int * (255 / state_attr('input_number.atrium_lichterkette_brightness', 'max') | int) }}\n{% endif %}", - "effect_list_template": "{{ state_attr('input_select.atrium_lichterkette_mode', 'options') }}", - "effect_template": "'{{ states('input_select.atrium_lichterkette_mode')}}'", - "turn_on": [ - { - "service": "button.press", - "target": { - "entity_id": "button.esphome_web_28a814_lichterkette_on" - }, - }, - { - "service": "input_boolean.turn_on", - "target": { - "entity_id": "input_boolean.atrium_lichterkette_power" - }, - }, - ], - "turn_off": [ - { - "service": "button.press", - "target": { - "entity_id": "button.esphome_web_28a814_lichterkette_off" - }, - }, - { - "service": "input_boolean.turn_off", - "target": { - "entity_id": "input_boolean.atrium_lichterkette_power" - }, - }, - ], - "set_level": [ - { - "variables": { - "scaled": "{{ (brightness / (255 / state_attr('input_number.atrium_lichterkette_brightness', 'max'))) | round | int }}", - "diff": "{{ scaled | int - states('input_number.atrium_lichterkette_brightness') | int }}", - "direction": "{{ 'dim' if diff | int < 0 else 'bright' }}", - } - }, - { - "repeat": { - "count": "{{ diff | int | abs }}", - "sequence": [ - { - "service": "button.press", - "target": { - "entity_id": "button.esphome_web_28a814_lichterkette_{{ direction }}" - }, - }, - {"delay": {"milliseconds": 500}}, - ], - } - }, - { - "service": "input_number.set_value", - "data": { - "value": "{{ scaled }}", - "entity_id": "input_number.atrium_lichterkette_brightness", - }, - }, - ], - "set_effect": [ - { - "service": "button.press", - "target": { - "entity_id": "button.esphome_web_28a814_lichterkette_{{ effect }}" - }, - } - ], - } - }, - } - ] - }, - [ - "scaled: ", - "diff: ", - "direction: ", - ], - ), - ( - "cover", - { - "cover": [ - { - "platform": "template", - "covers": { - "large_garage_door": { - "device_class": "garage", - "friendly_name": "Large Garage Door", - "value_template": "{% if is_state('binary_sensor.large_garage_door', 'off') %}\n closed\n{% elif is_state('timer.large_garage_opening_timer', 'active') %}\n opening\n{% elif is_state('timer.large_garage_closing_timer', 'active') %} \n closing\n{% elif is_state('binary_sensor.large_garage_door', 'on') %}\n open\n{% endif %}\n", - "open_cover": [ - { - "condition": "state", - "entity_id": "binary_sensor.large_garage_door", - "state": "off", - }, - { - "action": "switch.turn_on", - "target": { - "entity_id": "switch.garage_door_relay_1" - }, - }, - { - "action": "timer.start", - "entity_id": "timer.large_garage_opening_timer", - }, - ], - "close_cover": [ - { - "condition": "state", - "entity_id": "binary_sensor.large_garage_door", - "state": "on", - }, - { - "action": "switch.turn_on", - "target": { - "entity_id": "switch.garage_door_relay_1" - }, - }, - { - "action": "timer.start", - "entity_id": "timer.large_garage_closing_timer", - }, - ], - "stop_cover": [ - { - "action": "switch.turn_on", - "target": { - "entity_id": "switch.garage_door_relay_1" - }, - }, - { - "action": "timer.cancel", - "entity_id": "timer.large_garage_opening_timer", - }, - { - "action": "timer.cancel", - "entity_id": "timer.large_garage_closing_timer", - }, - ], - } - }, - } - ] - }, - ["device_class: garage"], - ), - ( - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "motion_sensor": { - "friendly_name": "Motion Sensor", - "device_class": "motion", - "value_template": "{{ is_state('sensor.motion_detector', 'on') }}", - } - }, - }, - }, - ["device_class: motion"], - ), - ( - "sensor", - { - "sensor": { - "platform": "template", - "sensors": { - "some_sensor": { - "friendly_name": "Sensor", - "entity_id": "sensor.some_sensor", - "device_class": "timestamp", - "value_template": "{{ now().isoformat() }}", - } - }, - }, - }, - ["device_class: timestamp", "entity_id: sensor.some_sensor"], - ), - ], -) -async def test_legacy_deprecation_with_unique_objects( - hass: HomeAssistant, - domain: str, - config: dict, - strings_to_check: list[str], - issue_registry: ir.IssueRegistry, -) -> None: - """Test legacy configuration raises issue and unique objects are properly converted to valid configurations.""" - - await async_setup_component(hass, domain, config) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = next(iter(issue_registry.issues.values())) - - assert issue.domain == "template" - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.translation_placeholders is not None - for string in strings_to_check: - assert string in issue.translation_placeholders["config"] - - -@pytest.mark.parametrize( - ("domain", "config"), - [ - ( - "template", - {"template": [{"sensor": {"name": "test_template_sensor", "state": "OK"}}]}, - ), - ( - "template", - { - "template": [ - { - "triggers": {"trigger": "event", "event_type": "test"}, - "sensor": {"name": "test_template_sensor", "state": "OK"}, - } - ] - }, - ), - ], -) -async def test_modern_configuration_does_not_raise_issue( - hass: HomeAssistant, - domain: str, - config: dict, - issue_registry: ir.IssueRegistry, -) -> None: - """Test modern configuration does not raise issue.""" - - await async_setup_component(hass, domain, config) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 0 - - -async def test_yaml_config_recursion_depth(hass: HomeAssistant) -> None: - """Test recursion depth when formatting ConfigType.""" - - with pytest.raises(RecursionError): - format_migration_config({1: {2: {3: {4: {5: {6: [{7: {8: {9: {10: {}}}}}]}}}}}}) - - -@pytest.mark.parametrize( - ("domain", "config"), - [ - ( - "media_player", - { - "media_player": { - "platform": "template", - "name": "My Media Player", - "unique_id": "Foobar", - }, - }, - ), - ( - "climate", - { - "climate": { - "platform": "template", - "name": "My Climate", - "unique_id": "Foobar", - }, - }, - ), - ], -) -async def test_custom_integration_deprecation( - hass: HomeAssistant, - domain: str, - config: dict, - issue_registry: ir.IssueRegistry, -) -> None: - """Test that custom integrations do not create deprecations.""" - - create_legacy_template_issue(hass, config, domain) - assert len(issue_registry.issues) == 0 diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 1d96acdf0df314..0cdf7714e119b1 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -106,6 +106,19 @@ async def setup_weather( await setup_entity(hass, TEST_WEATHER, style, 1, config) +@pytest.mark.parametrize( + ("style", "config"), + [(ConfigurationStyle.LEGACY, TEST_LEGACY_REQUIRED)], +) +@pytest.mark.usefixtures("setup_weather") +async def test_legacy_template_creates_warning( + hass: HomeAssistant, caplog_setup_text +) -> None: + """Test legacy YAML configuration logs a warning.""" + assert len(hass.states.async_all("weather")) == 0 + assert "entities can only be configured under template:" in caplog_setup_text + + @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] ) @@ -131,25 +144,6 @@ async def test_template_state_exception(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("style", "config"), [ - ( - ConfigurationStyle.LEGACY, - { - "apparent_temperature_template": "{{ states('sensor.apparent_temperature') }}", - "attribution_template": "{{ states('sensor.attribution') }}", - "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", - "condition_template": "{{ states('sensor.condition') }}", - "dew_point_template": "{{ states('sensor.dew_point') }}", - "humidity_template": "{{ states('sensor.humidity') | int }}", - "ozone_template": "{{ states('sensor.ozone') }}", - "pressure_template": "{{ states('sensor.pressure') }}", - "temperature_template": "{{ states('sensor.temperature') | float }}", - "unique_id": "abc123", - "visibility_template": "{{ states('sensor.visibility') }}", - "wind_bearing_template": "{{ states('sensor.wind_bearing') }}", - "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", - "wind_speed_template": "{{ states('sensor.wind_speed') }}", - }, - ), ( ConfigurationStyle.MODERN, { @@ -233,9 +227,7 @@ async def test_template_state_exception(hass: HomeAssistant) -> None: ], ) @pytest.mark.usefixtures("setup_weather") -async def test_template_state_text( - hass: HomeAssistant, style: ConfigurationStyle -) -> None: +async def test_template_state_text(hass: HomeAssistant) -> None: """Test the state text of a template.""" await async_trigger(hass, "sensor.condition", "sunny") for entity_id, v_attr, value in ( @@ -257,10 +249,7 @@ async def test_template_state_text( state = hass.states.get(TEST_WEATHER.entity_id) assert state is not None assert state.state == "sunny" - # Legacy template entities do not support uv_index, modern and trigger do. - assert state.attributes.get(v_attr) == value or ( - entity_id == "sensor.uv_index" and style == ConfigurationStyle.LEGACY - ) + assert state.attributes.get(v_attr) == value await async_trigger(hass, "sensor.condition", "None") state = hass.states.get(TEST_WEATHER.entity_id) @@ -269,53 +258,23 @@ async def test_template_state_text( @pytest.mark.parametrize( - ("style", "config"), + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "config", [ - ( - ConfigurationStyle.LEGACY, - { - "forecast_daily_template": "{{ state_attr('sensor.forecast', 'forecast') }}", - "forecast_hourly_template": "{{ state_attr('sensor.forecast', 'forecast') }}", - "forecast_twice_daily_template": "{{ state_attr('sensor.forecast_twice_daily', 'forecast') }}", - **TEST_LEGACY_REQUIRED, - }, - ), - ( - ConfigurationStyle.MODERN, - { - "forecast_daily_template": "{{ state_attr('sensor.forecast', 'forecast') }}", - "forecast_hourly_template": "{{ state_attr('sensor.forecast', 'forecast') }}", - "forecast_twice_daily_template": "{{ state_attr('sensor.forecast_twice_daily', 'forecast') }}", - **TEST_LEGACY_REQUIRED, - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "forecast_daily_template": "{{ state_attr('sensor.forecast', 'forecast') }}", - "forecast_hourly_template": "{{ state_attr('sensor.forecast', 'forecast') }}", - "forecast_twice_daily_template": "{{ state_attr('sensor.forecast_twice_daily', 'forecast') }}", - **TEST_LEGACY_REQUIRED, - }, - ), - ( - ConfigurationStyle.MODERN, - { - "forecast_daily": "{{ state_attr('sensor.forecast', 'forecast') }}", - "forecast_hourly": "{{ state_attr('sensor.forecast', 'forecast') }}", - "forecast_twice_daily": "{{ state_attr('sensor.forecast_twice_daily', 'forecast') }}", - **TEST_MODERN_REQUIRED, - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "forecast_daily": "{{ state_attr('sensor.forecast', 'forecast') }}", - "forecast_hourly": "{{ state_attr('sensor.forecast', 'forecast') }}", - "forecast_twice_daily": "{{ state_attr('sensor.forecast_twice_daily', 'forecast') }}", - **TEST_MODERN_REQUIRED, - }, - ), + { + "forecast_daily_template": "{{ state_attr('sensor.forecast', 'forecast') }}", + "forecast_hourly_template": "{{ state_attr('sensor.forecast', 'forecast') }}", + "forecast_twice_daily_template": "{{ state_attr('sensor.forecast_twice_daily', 'forecast') }}", + **TEST_LEGACY_REQUIRED, + }, + { + "forecast_daily": "{{ state_attr('sensor.forecast', 'forecast') }}", + "forecast_hourly": "{{ state_attr('sensor.forecast', 'forecast') }}", + "forecast_twice_daily": "{{ state_attr('sensor.forecast_twice_daily', 'forecast') }}", + **TEST_MODERN_REQUIRED, + }, ], ) @pytest.mark.usefixtures("setup_weather") @@ -403,15 +362,6 @@ async def test_forecasts(hass: HomeAssistant, snapshot: SnapshotAssertion) -> No @pytest.mark.parametrize( ("style", "config"), [ - ( - ConfigurationStyle.LEGACY, - { - "forecast_daily_template": "{{ state_attr('sensor.forecast_daily', 'forecast') }}", - "forecast_hourly_template": "{{ state_attr('sensor.forecast_hourly', 'forecast') }}", - "forecast_twice_daily_template": "{{ state_attr('sensor.forecast_twice_daily', 'forecast') }}", - **TEST_LEGACY_REQUIRED, - }, - ), ( ConfigurationStyle.MODERN, { @@ -554,15 +504,6 @@ async def test_forecasts_invalid( @pytest.mark.parametrize( ("style", "config"), [ - ( - ConfigurationStyle.LEGACY, - { - "forecast_daily_template": "{{ state_attr('sensor.forecast_daily', 'forecast') }}", - "forecast_hourly_template": "{{ state_attr('sensor.forecast_hourly', 'forecast') }}", - "forecast_twice_daily_template": "{{ state_attr('sensor.forecast_twice_daily', 'forecast') }}", - **TEST_LEGACY_REQUIRED, - }, - ), ( ConfigurationStyle.MODERN, { From 9b2eea920ff2988cf24a24d508bdd465175fff66 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 5 May 2026 21:19:59 +0100 Subject: [PATCH 10/12] Add V2C LED lights (#169778) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/v2c/__init__.py | 1 + homeassistant/components/v2c/icons.json | 8 + homeassistant/components/v2c/light.py | 126 +++++++++++++ homeassistant/components/v2c/strings.json | 8 + tests/components/v2c/fixtures/get_data.json | 2 + .../v2c/snapshots/test_diagnostics.ambr | 4 +- .../components/v2c/snapshots/test_light.ambr | 120 +++++++++++++ tests/components/v2c/test_light.py | 168 ++++++++++++++++++ 8 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/v2c/light.py create mode 100644 tests/components/v2c/snapshots/test_light.ambr create mode 100644 tests/components/v2c/test_light.py diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index a6e24f2f2a7f3b..b32dcd94d7ff74 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -10,6 +10,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.LIGHT, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/v2c/icons.json b/homeassistant/components/v2c/icons.json index 29a0ecd20810e7..fe1b4b8a6483d7 100644 --- a/homeassistant/components/v2c/icons.json +++ b/homeassistant/components/v2c/icons.json @@ -1,5 +1,13 @@ { "entity": { + "light": { + "light_led": { + "default": "mdi:led-on" + }, + "logo_led": { + "default": "mdi:led-on" + } + }, "sensor": { "battery_power": { "default": "mdi:home-battery" diff --git a/homeassistant/components/v2c/light.py b/homeassistant/components/v2c/light.py new file mode 100644 index 00000000000000..6f589434ee0cf5 --- /dev/null +++ b/homeassistant/components/v2c/light.py @@ -0,0 +1,126 @@ +"""Light platform for V2C EVSE LEDs.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from pytrydan import Trydan, TrydanData + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, + LightEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness + +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator +from .entity import V2CBaseEntity + +LED_ON_VALUE = 100 +LED_OFF_VALUE = 0 +BRIGHTNESS_SCALE = (LED_OFF_VALUE, LED_ON_VALUE) + + +@dataclass(frozen=True, kw_only=True) +class V2CLightEntityDescription(LightEntityDescription): + """Describes V2C EVSE light entity.""" + + supports_brightness: bool = False + value_fn: Callable[[TrydanData], int | None] + update_fn: Callable[[Trydan, int], Coroutine[Any, Any, None]] + + +TRYDAN_LIGHTS = ( + V2CLightEntityDescription( + key="light_led", + translation_key="light_led", + entity_registry_enabled_default=False, + value_fn=lambda evse_data: evse_data.light_led, + update_fn=lambda evse, value: evse.light_led(value), + ), + V2CLightEntityDescription( + key="logo_led", + translation_key="logo_led", + supports_brightness=True, + value_fn=lambda evse_data: evse_data.logo_led, + update_fn=lambda evse, value: evse.logo_led(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: V2CConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up V2C Trydan light platform.""" + coordinator = config_entry.runtime_data + data = coordinator.data + assert data is not None + + async_add_entities( + V2CLightEntity( + coordinator, + description, + config_entry.entry_id, + ) + for description in TRYDAN_LIGHTS + if description.value_fn(data) is not None + ) + + +class V2CLightEntity(V2CBaseEntity, LightEntity): + """Representation of V2C EVSE LED light entity.""" + + entity_description: V2CLightEntityDescription + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: V2CLightEntityDescription, + entry_id: str, + ) -> None: + """Initialize the V2C light entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + self._attr_color_mode = ( + ColorMode.BRIGHTNESS if description.supports_brightness else ColorMode.ONOFF + ) + self._attr_supported_color_modes = {self._attr_color_mode} + + @property + def brightness(self) -> int | None: + """Return the light brightness.""" + if not self.entity_description.supports_brightness: + return None + value = self.entity_description.value_fn(self.data) + if value is None: + return None + return value_to_brightness(BRIGHTNESS_SCALE, value) + + @property + def is_on(self) -> bool | None: + """Return true if the light is on.""" + value = self.entity_description.value_fn(self.data) + if value is None: + return None + return value > 0 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the LED.""" + value = LED_ON_VALUE + if self.entity_description.supports_brightness: + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + value = round(brightness_to_value(BRIGHTNESS_SCALE, brightness)) + if brightness: + value = max(value, 1) + await self.entity_description.update_fn(self.coordinator.evse, value) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the LED.""" + await self.entity_description.update_fn(self.coordinator.evse, LED_OFF_VALUE) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index 39453ebb625765..eeb4a849d8c2b8 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -30,6 +30,14 @@ "name": "Ready" } }, + "light": { + "light_led": { + "name": "Light LED" + }, + "logo_led": { + "name": "Logo LED" + } + }, "number": { "intensity": { "name": "Intensity" diff --git a/tests/components/v2c/fixtures/get_data.json b/tests/components/v2c/fixtures/get_data.json index 7c250dee021bfb..1ee52e02aefa07 100644 --- a/tests/components/v2c/fixtures/get_data.json +++ b/tests/components/v2c/fixtures/get_data.json @@ -17,6 +17,8 @@ "MinIntensity": 6, "MaxIntensity": 16, "PauseDynamic": 0, + "LightLED": 25, + "LogoLED": 75, "FirmwareVersion": "2.1.7", "DynamicPowerMode": 2, "ContractedPower": 4600 diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index 2ccc3f4f21a984..fb1cbe25015f4d 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -22,8 +22,8 @@ 'unique_id': 'ABC123', 'version': 1, }), - 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=None, charge_energy=1.8, charge_mode=None, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=None, logo_led=None, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", + 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=None, charge_energy=1.8, charge_mode=None, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=25, logo_led=75, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", 'host_status': 200, - 'raw_data': '{"ID":"ABC123","ChargeState":2,"ReadyState":0,"ChargePower":1500.27,"ChargeEnergy":1.8,"SlaveError":4,"ChargeTime":4355,"HousePower":0.0,"FVPower":0.0,"BatteryPower":0.0,"Paused":0,"Locked":0,"Timer":0,"Intensity":6,"Dynamic":0,"MinIntensity":6,"MaxIntensity":16,"PauseDynamic":0,"FirmwareVersion":"2.1.7","DynamicPowerMode":2,"ContractedPower":4600}', + 'raw_data': '{"ID":"ABC123","ChargeState":2,"ReadyState":0,"ChargePower":1500.27,"ChargeEnergy":1.8,"SlaveError":4,"ChargeTime":4355,"HousePower":0.0,"FVPower":0.0,"BatteryPower":0.0,"Paused":0,"Locked":0,"Timer":0,"Intensity":6,"Dynamic":0,"MinIntensity":6,"MaxIntensity":16,"PauseDynamic":0,"LightLED":25,"LogoLED":75,"FirmwareVersion":"2.1.7","DynamicPowerMode":2,"ContractedPower":4600}', }) # --- diff --git a/tests/components/v2c/snapshots/test_light.ambr b/tests/components/v2c/snapshots/test_light.ambr new file mode 100644 index 00000000000000..a156fc3641d4bc --- /dev/null +++ b/tests/components/v2c/snapshots/test_light.ambr @@ -0,0 +1,120 @@ +# serializer version: 1 +# name: test_light[light.evse_1_1_1_1_light_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.evse_1_1_1_1_light_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Light LED', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light LED', + 'platform': 'v2c', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_led', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_light_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_light[light.evse_1_1_1_1_light_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'EVSE 1.1.1.1 Light LED', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.evse_1_1_1_1_light_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light[light.evse_1_1_1_1_logo_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.evse_1_1_1_1_logo_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Logo LED', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Logo LED', + 'platform': 'v2c', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'logo_led', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_logo_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_light[light.evse_1_1_1_1_logo_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 192, + 'color_mode': , + 'friendly_name': 'EVSE 1.1.1.1 Logo LED', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.evse_1_1_1_1_logo_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/v2c/test_light.py b/tests/components/v2c/test_light.py new file mode 100644 index 00000000000000..71b826871216e5 --- /dev/null +++ b/tests/components/v2c/test_light.py @@ -0,0 +1,168 @@ +"""Test the V2C light platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_light( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test states of the light entities.""" + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_light_turn_on_off( + hass: HomeAssistant, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning light entities on and off.""" + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.evse_1_1_1_1_light_led"}, + blocking=True, + ) + + mock_v2c_client.light_led.assert_called_once_with(100) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.evse_1_1_1_1_logo_led"}, + blocking=True, + ) + + mock_v2c_client.logo_led.assert_called_once_with(0) + + +async def test_logo_led_set_brightness( + hass: HomeAssistant, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting Logo LED brightness.""" + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.evse_1_1_1_1_logo_led", + ATTR_BRIGHTNESS: 128, + }, + blocking=True, + ) + + mock_v2c_client.logo_led.assert_called_once_with(50) + + +async def test_logo_led_set_low_brightness( + hass: HomeAssistant, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting Logo LED low brightness.""" + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.evse_1_1_1_1_logo_led", + ATTR_BRIGHTNESS: 1, + }, + blocking=True, + ) + + mock_v2c_client.logo_led.assert_called_once_with(1) + + +async def test_light_led_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Light LED entity is disabled by default.""" + + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + + entity_id = "light.evse_1_1_1_1_light_led" + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None + + +@pytest.mark.parametrize( + ("field", "entity_id"), + [ + ("light_led", "light.evse_1_1_1_1_light_led"), + ("logo_led", "light.evse_1_1_1_1_logo_led"), + ], +) +async def test_led_not_created_when_missing( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, + field: str, + entity_id: str, +) -> None: + """Test missing LED entities are not created.""" + setattr(mock_v2c_client.get_data.return_value, field, None) + + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + + assert entity_registry.async_get(entity_id) is None + assert hass.states.get(entity_id) is None + + +async def test_logo_led_enabled_when_present( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Logo LED entity is enabled when supported.""" + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + + entity_id = "light.evse_1_1_1_1_logo_led" + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is None + assert hass.states.get(entity_id) is not None From d1295fa260a5f8f673e1ddf2b5f0c26689b61aad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 May 2026 22:20:28 +0200 Subject: [PATCH 11/12] Validate yaml matches implementation in automation options_supported tests (#169798) --- tests/components/common.py | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/components/common.py b/tests/components/common.py index e8cb0035e13f24..aac1943ff094e7 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -5,6 +5,7 @@ from enum import StrEnum import itertools import logging +from pathlib import Path from typing import Any, TypedDict import pytest @@ -44,6 +45,7 @@ ) from homeassistant.helpers.typing import UNDEFINED, TemplateVarsType, UndefinedType from homeassistant.setup import async_setup_component +from homeassistant.util.yaml import load_yaml_dict from tests.common import MockConfigEntry, mock_device_registry @@ -1279,6 +1281,35 @@ async def _validate_condition_options( await async_validate_condition_config(hass, config) +def _get_yaml_fields(automation_key: str, yaml_type: str) -> dict[str, Any]: + """Load a conditions.yaml or triggers.yaml and return the fields for a key.""" + domain, key = automation_key.split(".", 1) + yaml_path = ( + Path(__file__).parents[2] + / "homeassistant" + / "components" + / domain + / f"{yaml_type}.yaml" + ) + data = load_yaml_dict(str(yaml_path)) + # YAML anchors (keys starting with '.') are included in the parsed dict; + # the actual entry uses the plain key name. + entry = data.get(key, {}) + return entry.get("fields", {}) + + +def _assert_yaml_has_field( + yaml_file: str, automation_key: str, field: str, *, expected: bool +) -> None: + """Assert that a field is present or absent in a yaml description.""" + yaml_fields = _get_yaml_fields(automation_key, yaml_file) + has_field = field in yaml_fields + assert has_field == expected, ( + f"{automation_key}: {yaml_file}.yaml {'has' if has_field else 'is missing'}" + f" '{field}', but expected {expected}" + ) + + async def assert_condition_options_supported( hass: HomeAssistant, condition: str, @@ -1294,7 +1325,14 @@ async def assert_condition_options_supported( - Accepts/rejects behavior depending on supports_behavior - Accepts/rejects duration depending on supports_duration - Rejects unknown options + - Condition yaml description matches supports_behavior / supports_duration """ + # Verify that the yaml description matches the flags + _assert_yaml_has_field( + "conditions", condition, "behavior", expected=supports_behavior + ) + _assert_yaml_has_field("conditions", condition, "for", expected=supports_duration) + # Minimal config should always be valid # If there are no base options, also test that options can be omitted or be empty supports_empty = not bool(base_options) @@ -1359,7 +1397,12 @@ async def assert_trigger_options_supported( - Accepts/rejects behavior depending on supports_behavior - Accepts/rejects duration depending on supports_duration - Rejects unknown options + - Trigger yaml description matches supports_behavior / supports_duration """ + # Verify that the yaml description matches the flags + _assert_yaml_has_field("triggers", trigger, "behavior", expected=supports_behavior) + _assert_yaml_has_field("triggers", trigger, "for", expected=supports_duration) + # Minimal config should always be valid supports_empty = not bool(base_options) await _validate_trigger_options(hass, trigger, None, valid=supports_empty) From 4c8f37fef615bf741aa52391fdd16efc81e1bc48 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 May 2026 22:23:14 +0200 Subject: [PATCH 12/12] Bump tuya-device-handlers to 0.0.19 (#169848) --- homeassistant/components/tuya/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index abd3ef4558fb8a..2b36d6ea0db54b 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -44,7 +44,7 @@ "iot_class": "cloud_push", "loggers": ["tuya_sharing"], "requirements": [ - "tuya-device-handlers==0.0.18", + "tuya-device-handlers==0.0.19", "tuya-device-sharing-sdk==0.2.8" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 3a9e16846929b9..54765b415d46a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3178,7 +3178,7 @@ ttls==1.8.3 ttn_client==1.3.0 # homeassistant.components.tuya -tuya-device-handlers==0.0.18 +tuya-device-handlers==0.0.19 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1cdd33a2c9cf45..261f0c6d11b9b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2699,7 +2699,7 @@ ttls==1.8.3 ttn_client==1.3.0 # homeassistant.components.tuya -tuya-device-handlers==0.0.18 +tuya-device-handlers==0.0.19 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.8