From bc66c2610e9b4fcb95cfcad0d56d67543585560d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:44:29 +0200 Subject: [PATCH 1/7] Update infrared-protocols to 5.8.0 (#172804) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- homeassistant/components/infrared/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index 4faf0eff0d7ec3..0252093da26ba6 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==5.6.1"] + "requirements": ["infrared-protocols==5.8.0"] } diff --git a/requirements.txt b/requirements.txt index 9c83d376ea84a9..822a8a77247fbe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.5.5 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==5.6.1 +infrared-protocols==5.8.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.47.0 diff --git a/requirements_all.txt b/requirements_all.txt index 17097a345cc7e4..27c3462eb82b2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1359,7 +1359,7 @@ influxdb-client==1.50.0 influxdb==5.3.1 # homeassistant.components.infrared -infrared-protocols==5.6.1 +infrared-protocols==5.8.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 From a32d028e3d1e7ede47433c458063df274a7d52d2 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 2 Jun 2026 08:04:20 +0200 Subject: [PATCH 2/7] Update knx-frontend to 2026.6.1.213802 (#172806) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 2fb9d53ee0d417..2f41732d03dc27 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.15.0", "xknxproject==3.9.0", - "knx-frontend==2026.4.30.60856" + "knx-frontend==2026.6.1.213802" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 27c3462eb82b2a..3ae2dfaff924bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1423,7 +1423,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.4.30.60856 +knx-frontend==2026.6.1.213802 # homeassistant.components.kraken krakenex==2.2.2 From 772c426d5dcf463370582399d912b4d4c186f51a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Jun 2026 09:34:12 +0200 Subject: [PATCH 3/7] Add zone triggers occupancy detected/cleared (#172438) --- homeassistant/components/zone/icons.json | 6 + homeassistant/components/zone/strings.json | 26 +++ homeassistant/components/zone/trigger.py | 72 +++++++++ homeassistant/components/zone/triggers.yaml | 16 ++ tests/components/common.py | 39 +++-- tests/components/zone/test_trigger.py | 171 +++++++++++++++++++- 6 files changed, 318 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zone/icons.json b/homeassistant/components/zone/icons.json index 90d90c50565e14..f582e6b65a4953 100644 --- a/homeassistant/components/zone/icons.json +++ b/homeassistant/components/zone/icons.json @@ -10,6 +10,12 @@ }, "left": { "trigger": "mdi:map-marker-minus" + }, + "occupancy_cleared": { + "trigger": "mdi:account-off" + }, + "occupancy_detected": { + "trigger": "mdi:account-group" } } } diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json index b511a57c79c6ca..43d0c2986c2e92 100644 --- a/homeassistant/components/zone/strings.json +++ b/homeassistant/components/zone/strings.json @@ -43,6 +43,32 @@ } }, "name": "Left zone" + }, + "occupancy_cleared": { + "description": "Triggers when a zone transitions from occupied to unoccupied.", + "fields": { + "for": { + "name": "[%key:component::zone::common::trigger_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::triggers::occupancy_detected::fields::zone::description%]", + "name": "[%key:component::zone::triggers::occupancy_detected::fields::zone::name%]" + } + }, + "name": "Zone occupancy cleared" + }, + "occupancy_detected": { + "description": "Triggers when a zone transitions to an occupied state.", + "fields": { + "for": { + "name": "[%key:component::zone::common::trigger_for_name%]" + }, + "zone": { + "description": "The zone to monitor.", + "name": "Zone" + } + }, + "name": "Zone occupancy detected" } } } diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index 608e154beaf383..13d067b0ff2768 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -10,7 +10,9 @@ ATTR_FRIENDLY_NAME, CONF_ENTITY_ID, CONF_EVENT, + CONF_FOR, CONF_OPTIONS, + CONF_TARGET, CONF_ZONE, ) from homeassistant.core import ( @@ -203,10 +205,80 @@ def is_valid_state(self, state: State) -> bool: return not self._in_target_zone(state) +_OCCUPANCY_TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS, default={}): { + vol.Required(CONF_ZONE): cv.entity_domain("zone"), + vol.Optional(CONF_FOR): cv.positive_time_period, + }, + } +) + + +class _ZoneOccupancyTriggerBase(EntityTriggerBase): + """Base for zone occupancy triggers (single zone, no behavior).""" + + _domain_specs = {"zone": DomainSpec()} + _schema = _OCCUPANCY_TRIGGER_SCHEMA + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config and synthesize a target from the zone option. + + We synthesize a target because we allow users to pick a single zone + to monitor, not a target. + """ + config = cast(ConfigType, cls._schema(config)) + config[CONF_TARGET] = {CONF_ENTITY_ID: [config[CONF_OPTIONS][CONF_ZONE]]} + return config + + @staticmethod + def _occupancy_count(state: State) -> int | None: + """Return the zone's persons-in-zone count; None if unparsable.""" + try: + return int(state.state) + except TypeError, ValueError: + return None + + @classmethod + def _is_occupied(cls, state: State) -> bool: + """Return True if the zone has at least one occupant.""" + count = cls._occupancy_count(state) + return count is not None and count >= 1 + + +class OccupancyDetectedTrigger(_ZoneOccupancyTriggerBase): + """Trigger when a zone transitions to an occupied state.""" + + def is_valid_state(self, state: State) -> bool: + """Check that the zone is occupied.""" + return self._is_occupied(state) + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check that the zone was previously not occupied.""" + return not self._is_occupied(from_state) + + +class OccupancyClearedTrigger(_ZoneOccupancyTriggerBase): + """Trigger when a zone transitions from occupied to unoccupied.""" + + def is_valid_state(self, state: State) -> bool: + """Check that the zone is empty (count == 0).""" + return self._occupancy_count(state) == 0 + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check that the zone was previously occupied.""" + return self._is_occupied(from_state) + + TRIGGERS: dict[str, type[Trigger]] = { "_": LegacyZoneTrigger, "entered": EnteredZoneTrigger, "left": LeftZoneTrigger, + "occupancy_detected": OccupancyDetectedTrigger, + "occupancy_cleared": OccupancyClearedTrigger, } diff --git a/homeassistant/components/zone/triggers.yaml b/homeassistant/components/zone/triggers.yaml index 8828c8b128acac..81526345b799f8 100644 --- a/homeassistant/components/zone/triggers.yaml +++ b/homeassistant/components/zone/triggers.yaml @@ -24,3 +24,19 @@ entered: *trigger_zone left: *trigger_zone + +.trigger_occupancy: &trigger_occupancy + fields: + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +occupancy_detected: *trigger_occupancy +occupancy_cleared: *trigger_occupancy diff --git a/tests/components/common.py b/tests/components/common.py index 84dabaf926f968..4b6bf9609ab877 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -1586,12 +1586,12 @@ async def _validate_trigger_options( options: dict[str, Any] | None, *, valid: bool, + supports_target: bool = True, ) -> None: """Assert that a trigger accepts or rejects the given options during validation.""" - trigger_config: dict[str, Any] = { - CONF_PLATFORM: trigger, - CONF_TARGET: {ATTR_LABEL_ID: "test_label"}, - } + trigger_config: dict[str, Any] = {CONF_PLATFORM: trigger} + if supports_target: + trigger_config[CONF_TARGET] = {ATTR_LABEL_ID: "test_label"} if options is not None: trigger_config[CONF_OPTIONS] = options if valid: @@ -1608,6 +1608,7 @@ async def assert_trigger_options_supported( *, supports_behavior: bool, supports_duration: bool, + supports_target: bool = True, ) -> None: """Assert which options a trigger supports. @@ -1624,9 +1625,15 @@ async def assert_trigger_options_supported( # Minimal config should always be valid supports_empty = not bool(base_options) - await _validate_trigger_options(hass, trigger, None, valid=supports_empty) - await _validate_trigger_options(hass, trigger, {}, valid=supports_empty) - await _validate_trigger_options(hass, trigger, base_options, valid=True) + await _validate_trigger_options( + hass, trigger, None, valid=supports_empty, supports_target=supports_target + ) + await _validate_trigger_options( + hass, trigger, {}, valid=supports_empty, supports_target=supports_target + ) + await _validate_trigger_options( + hass, trigger, base_options, valid=True, supports_target=supports_target + ) def _merge(extra: dict[str, Any]) -> dict[str, Any]: return {**(base_options or {}), **extra} @@ -1634,18 +1641,30 @@ def _merge(extra: dict[str, Any]) -> dict[str, Any]: # Behavior for behavior in ("each", "first", "all"): await _validate_trigger_options( - hass, trigger, _merge({"behavior": behavior}), valid=supports_behavior + hass, + trigger, + _merge({"behavior": behavior}), + valid=supports_behavior, + supports_target=supports_target, ) # Duration for for_value in ({"seconds": 5}, "00:00:05", 5): await _validate_trigger_options( - hass, trigger, _merge({"for": for_value}), valid=supports_duration + hass, + trigger, + _merge({"for": for_value}), + valid=supports_duration, + supports_target=supports_target, ) # Unknown option should always be rejected await _validate_trigger_options( - hass, trigger, _merge({"unknown_option": True}), valid=False + hass, + trigger, + _merge({"unknown_option": True}), + valid=False, + supports_target=supports_target, ) diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index 8d268fe5be6ba7..648ef7dad698a4 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -1,18 +1,26 @@ """The tests for the location automation.""" +from datetime import timedelta from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol from homeassistant.components import automation, zone -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF +from homeassistant.const import ( + ATTR_ENTITY_ID, + ENTITY_MATCH_ALL, + SERVICE_TURN_OFF, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.setup import async_setup_component -from tests.common import mock_component +from tests.common import async_fire_time_changed, mock_component from tests.components.common import ( TriggerStateDescription, assert_trigger_behavior_all, @@ -556,3 +564,162 @@ async def test_zone_trigger_behavior_all( trigger_options=trigger_options, states=states, ) + + +# --- Zone occupancy trigger tests --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_key"), + ["zone.occupancy_detected", "zone.occupancy_cleared"], +) +async def test_zone_occupancy_trigger_options_validation( + hass: HomeAssistant, + trigger_key: str, +) -> None: + """Test that occupancy triggers support the expected options.""" + await assert_trigger_options_supported( + hass, + trigger_key, + {"zone": ZONE_HOME}, + supports_behavior=False, + supports_duration=True, + supports_target=False, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_key", "from_state", "to_state", "should_fire"), + [ + # occupancy_detected + pytest.param("zone.occupancy_detected", "0", "1", True, id="detected_0_to_1"), + pytest.param("zone.occupancy_detected", "0", "3", True, id="detected_0_to_3"), + pytest.param("zone.occupancy_detected", "1", "2", False, id="detected_1_to_2"), + pytest.param("zone.occupancy_detected", "2", "0", False, id="detected_2_to_0"), + pytest.param( + "zone.occupancy_detected", + STATE_UNKNOWN, + "1", + False, + id="detected_unknown_to_1", + ), + pytest.param( + "zone.occupancy_detected", + STATE_UNAVAILABLE, + "1", + False, + id="detected_unavailable_to_1", + ), + pytest.param( + "zone.occupancy_detected", + "0", + STATE_UNAVAILABLE, + False, + id="detected_0_to_unavailable", + ), + # occupancy_cleared + pytest.param("zone.occupancy_cleared", "1", "0", True, id="cleared_1_to_0"), + pytest.param("zone.occupancy_cleared", "3", "0", True, id="cleared_3_to_0"), + pytest.param("zone.occupancy_cleared", "2", "1", False, id="cleared_2_to_1"), + pytest.param("zone.occupancy_cleared", "0", "1", False, id="cleared_0_to_1"), + pytest.param( + "zone.occupancy_cleared", + "1", + STATE_UNAVAILABLE, + False, + id="cleared_1_to_unavailable", + ), + pytest.param( + "zone.occupancy_cleared", + "1", + STATE_UNKNOWN, + False, + id="cleared_1_to_unknown", + ), + ], +) +async def test_zone_occupancy_trigger_transitions( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_key: str, + from_state: str, + to_state: str, + should_fire: bool, +) -> None: + """Test occupancy triggers fire on the expected numeric-state transitions.""" + hass.states.async_set(ZONE_HOME, from_state) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "trigger": trigger_key, + "options": {"zone": ZONE_HOME}, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + hass.states.async_set(ZONE_HOME, to_state) + await hass.async_block_till_done() + assert (len(service_calls) == 1) is should_fire + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_key", "from_value", "to_value", "revert_value"), + [ + ("zone.occupancy_detected", "0", "1", "0"), + ("zone.occupancy_cleared", "1", "0", "1"), + ], +) +async def test_zone_occupancy_trigger_for_duration( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], + trigger_key: str, + from_value: str, + to_value: str, + revert_value: str, +) -> None: + """Test that `for` delays the firing and an early revert cancels it.""" + hass.states.async_set(ZONE_HOME, from_value) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "trigger": trigger_key, + "options": {"zone": ZONE_HOME, "for": {"seconds": 5}}, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # Transition, then revert before the duration elapses -> no fire. + hass.states.async_set(ZONE_HOME, to_value) + await hass.async_block_till_done() + hass.states.async_set(ZONE_HOME, revert_value) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Transition and hold past the duration -> fire once. + hass.states.async_set(ZONE_HOME, to_value) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(service_calls) == 1 From 174ac9eafe47f45703e513c941811c10da745e22 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:42:33 +0200 Subject: [PATCH 4/7] Deprecate single-use CONCENTRATION_PARTS_PER_CUBIC_METER constant (#172553) --- .../components/accuweather/sensor.py | 11 +++--- homeassistant/const.py | 18 ++++++++- tests/test_const.py | 39 +++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 tests/test_const.py diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 4d16a8308773bc..f42c70cd395bb7 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -11,7 +11,6 @@ SensorStateClass, ) from homeassistant.const import ( - CONCENTRATION_PARTS_PER_CUBIC_METER, PERCENTAGE, UV_INDEX, UnitOfIrradiance, @@ -47,6 +46,8 @@ PARALLEL_UPDATES = 1 +PARTS_PER_CUBIC_METER = "p/m³" + @dataclass(frozen=True, kw_only=True) class AccuWeatherSensorDescription(SensorEntityDescription): @@ -81,7 +82,7 @@ class AccuWeatherSensorDescription(SensorEntityDescription): AccuWeatherSensorDescription( key="Grass", entity_registry_enabled_default=False, - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=PARTS_PER_CUBIC_METER, value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: { ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] @@ -107,7 +108,7 @@ class AccuWeatherSensorDescription(SensorEntityDescription): AccuWeatherSensorDescription( key="Mold", entity_registry_enabled_default=False, - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=PARTS_PER_CUBIC_METER, value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: { ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] @@ -116,7 +117,7 @@ class AccuWeatherSensorDescription(SensorEntityDescription): ), AccuWeatherSensorDescription( key="Ragweed", - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: { @@ -184,7 +185,7 @@ class AccuWeatherSensorDescription(SensorEntityDescription): ), AccuWeatherSensorDescription( key="Tree", - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: { diff --git a/homeassistant/const.py b/homeassistant/const.py index b51d71923baa5b..b32c7f65d1eab0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,9 +1,16 @@ """Constants used by Home Assistant components.""" from enum import StrEnum +from functools import partial from typing import TYPE_CHECKING, Final from .generated.entity_platforms import EntityPlatforms +from .helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from .util.event_type import EventType from .util.hass_dict import HassKey from .util.signal_type import SignalType @@ -758,7 +765,9 @@ class UnitOfPrecipitationDepth(StrEnum): CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "μg/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³" -CONCENTRATION_PARTS_PER_CUBIC_METER: Final = "p/m³" +_DEPRECATED_CONCENTRATION_PARTS_PER_CUBIC_METER = DeprecatedConstant( + "p/m³", "p/m³", "2027.7" +) CONCENTRATION_PARTS_PER_MILLION: Final = "ppm" CONCENTRATION_PARTS_PER_BILLION: Final = "ppb" @@ -992,3 +1001,10 @@ class EntityCategory(StrEnum): # This is not a hard limit, but caches and other # data structures will be pre-allocated to this size MAX_EXPECTED_ENTITY_IDS: Final = 16384 + +# These can be removed if no deprecated constants are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/tests/test_const.py b/tests/test_const.py new file mode 100644 index 00000000000000..db379f22cd7402 --- /dev/null +++ b/tests/test_const.py @@ -0,0 +1,39 @@ +"""Test const module.""" + +import pytest + +from homeassistant import const + +from .common import help_test_all, import_and_test_deprecated_constant + + +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(const) + + +@pytest.mark.parametrize( + ("replacement", "constant_name", "breaks_in_version"), + [ + ( + "p/m³", + "CONCENTRATION_PARTS_PER_CUBIC_METER", + "2027.7", + ), + ], +) +def test_deprecated_constant( + caplog: pytest.LogCaptureFixture, + replacement: str, + constant_name: str, + breaks_in_version: str, +) -> None: + """Test deprecated constants, where no replacement is provided.""" + import_and_test_deprecated_constant( + caplog, + const, + constant_name, + replacement, + replacement, + breaks_in_version, + ) From 36d2e85351fe1b4c2571830dfe94132d3f91d1ff Mon Sep 17 00:00:00 2001 From: jameson_uk <1040621+jamesonuk@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:04:20 +0100 Subject: [PATCH 5/7] alexa devices - media player code quality (#172650) --- .../components/alexa_devices/media_player.py | 31 ++++++------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/alexa_devices/media_player.py b/homeassistant/components/alexa_devices/media_player.py index fe94b98b118404..34fdeeed01da24 100644 --- a/homeassistant/components/alexa_devices/media_player.py +++ b/homeassistant/components/alexa_devices/media_player.py @@ -1,8 +1,7 @@ """Media player platform for Alexa Devices.""" -from dataclasses import dataclass from datetime import datetime -from typing import Any, Final +from typing import Any from aioamazondevices.structures import ( AmazonMediaControls, @@ -38,18 +37,6 @@ ) -@dataclass(frozen=True, kw_only=True) -class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription): - """Describes an Alexa Devices media player entity.""" - - -MEDIA_PLAYERS: Final = ( - AmazonDevicesMediaPlayerEntityDescription( - key="media", - ), -) - - async def async_setup_entry( hass: HomeAssistant, entry: AmazonConfigEntry, @@ -69,9 +56,10 @@ def _check_device() -> None: continue known_devices.add(serial_num) - new_entities.extend( - AlexaDevicesMediaPlayer(coordinator, serial_num, description) - for description in MEDIA_PLAYERS + new_entities.append( + AlexaDevicesMediaPlayer( + coordinator, serial_num, MediaPlayerEntityDescription(key="media") + ) ) if new_entities: @@ -85,8 +73,6 @@ def _check_device() -> None: class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity): """Representation of an Alexa device media player.""" - entity_description: AmazonDevicesMediaPlayerEntityDescription - _attr_name = None # Uses the device name _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_volume_step = 0.05 @@ -95,7 +81,7 @@ def __init__( self, coordinator: AmazonDevicesCoordinator, serial_num: str, - description: AmazonDevicesMediaPlayerEntityDescription, + description: MediaPlayerEntityDescription, ) -> None: """Initialize.""" self._prev_volume: int | None = None @@ -214,7 +200,7 @@ def media_position_updated_at(self) -> datetime | None: @property def media_content_type(self) -> MediaType | None: """Content type — tells HA what kind of media is playing.""" - if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]: + if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED): return MediaType.MUSIC return None @@ -227,7 +213,8 @@ async def async_play_media( **kwargs: Any, ) -> None: """Play a piece of media.""" - await self.async_call_alexa_music(media_id, media_type) + provider = media_type.value if isinstance(media_type, MediaType) else media_type + await self.async_call_alexa_music(media_id, provider) @alexa_api_call async def async_call_alexa_music( From d8b02ea6d6a2f4ec08a4a383a4010af56014e2f1 Mon Sep 17 00:00:00 2001 From: fdebrus <33791533+fdebrus@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:21:16 +0200 Subject: [PATCH 6/7] Add button platform to Vistapool (#172550) Co-authored-by: Claude --- .../components/vistapool/__init__.py | 2 +- homeassistant/components/vistapool/button.py | 73 +++++++ .../components/vistapool/quality_scale.yaml | 8 +- .../components/vistapool/strings.json | 8 + .../vistapool/snapshots/test_button.ambr | 51 +++++ tests/components/vistapool/test_button.py | 197 ++++++++++++++++++ 6 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/vistapool/button.py create mode 100644 tests/components/vistapool/snapshots/test_button.ambr create mode 100644 tests/components/vistapool/test_button.py diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index 51e0eb585330da..50230ab7686e86 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] @dataclass diff --git a/homeassistant/components/vistapool/button.py b/homeassistant/components/vistapool/button.py new file mode 100644 index 00000000000000..2432dc505ad044 --- /dev/null +++ b/homeassistant/components/vistapool/button.py @@ -0,0 +1,73 @@ +"""Vistapool Button entities.""" + +import asyncio + +from aioaquarite import AquariteError + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VistapoolConfigEntry +from .const import DOMAIN +from .coordinator import VistapoolDataUpdateCoordinator +from .entity import VistapoolEntity + +PARALLEL_UPDATES = 1 + +_HASLED_PATH = "main.hasLED" +_LIGHT_STATUS_PATH = "light.status" +_LED_PULSE_DELAY_SECONDS = 1.0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Vistapool buttons for every pool that has an LED fixture.""" + async_add_entities( + VistapoolLEDPulseButton(coordinator) + for coordinator in entry.runtime_data.coordinators.values() + if coordinator.get_value(_HASLED_PATH) + ) + + +class VistapoolLEDPulseButton(VistapoolEntity, ButtonEntity): + """Power-cycle the pool light to advance the LED fixture's color. + + Mirrors the "Next" button under LED Color in the Vistapool app's + Illumination screen. If the light is on, sends light.status=0, waits a + moment, then light.status=1; the physical LED fixture advances to the + next color on power-on. If the light is off, just turns it on. + """ + + _attr_translation_key = "led_pulse" + + def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None: + """Initialize the LED pulse button.""" + super().__init__(coordinator) + self._attr_unique_id = self.build_unique_id("led_pulse") + + async def async_press(self) -> None: + """Send a color-advance pulse to the pool LED fixture.""" + try: + if self.coordinator.get_value(_LIGHT_STATUS_PATH) in (True, "1"): + await self.coordinator.api.set_value( + self.coordinator.pool_id, _LIGHT_STATUS_PATH, 0 + ) + await asyncio.sleep(_LED_PULSE_DELAY_SECONDS) + await self.coordinator.api.set_value( + self.coordinator.pool_id, _LIGHT_STATUS_PATH, 1 + ) + except AquariteError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_failed", + translation_placeholders={"entity": self.entity_id}, + ) from err + # Optimistically reflect the just-written value so a rapid second press + # doesn't read the stale off-state before the Firestore push round-trips. + self.coordinator.data.setdefault("light", {})["status"] = 1 + self.coordinator.async_set_updated_data(self.coordinator.data) diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index 72ca9758594650..4ffcc19d98ae7f 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -2,7 +2,7 @@ rules: # Bronze action-setup: status: exempt - comment: No service actions in initial sensor-only platform + comment: No integration-specific service actions; entities use platform-standard actions only appropriate-polling: done brands: done common-modules: done @@ -11,7 +11,7 @@ rules: dependency-transparency: done docs-actions: status: exempt - comment: No service actions in initial sensor-only platform + comment: No integration-specific service actions to document docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -24,9 +24,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: No user actions (sensor-only platform) + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index 1ef44f92ec3e5c..5ceceab013be61 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -25,6 +25,11 @@ } }, "entity": { + "button": { + "led_pulse": { + "name": "LED next color" + } + }, "sensor": { "chlorine": { "name": "Chlorine" @@ -59,6 +64,9 @@ "no_pools": { "message": "No pools were found on this account." }, + "set_failed": { + "message": "Failed to set {entity}." + }, "update_failed": { "message": "Error fetching data from Vistapool." } diff --git a/tests/components/vistapool/snapshots/test_button.ambr b/tests/components/vistapool/snapshots/test_button.ambr new file mode 100644 index 00000000000000..a79bb0822310d7 --- /dev/null +++ b/tests/components/vistapool/snapshots/test_button.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_all_entities[button.my_pool_led_next_color-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.my_pool_led_next_color', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'LED next color', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED next color', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led_pulse', + 'unique_id': 'ABCDEF1234567890-led_pulse', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.my_pool_led_next_color-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool LED next color', + }), + 'context': , + 'entity_id': 'button.my_pool_led_next_color', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/vistapool/test_button.py b/tests/components/vistapool/test_button.py new file mode 100644 index 00000000000000..30a0e1122dd6c2 --- /dev/null +++ b/tests/components/vistapool/test_button.py @@ -0,0 +1,197 @@ +"""Tests for the Vistapool button platform.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from aioaquarite import AquariteError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +_BUTTON = "button.my_pool_led_next_color" +_LED_DATA = {"main": {"hasLED": 1, "version": 1}, "light": {"status": 0}} + + +@pytest.fixture(autouse=True) +def _only_button_platform() -> Generator[None]: + """Restrict integration setup to the button platform for these tests.""" + with patch("homeassistant.components.vistapool.PLATFORMS", [Platform.BUTTON]): + yield + + +@pytest.fixture(autouse=True) +def _skip_pulse_delay() -> Generator[None]: + """Skip the LED pulse delay so tests don't actually sleep.""" + with patch("homeassistant.components.vistapool.button._LED_PULSE_DELAY_SECONDS", 0): + yield + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the LED-pulse button when hasLED is set.""" + mock_vistapool_client.fetch_pool_data.return_value = _LED_DATA + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_button_not_created_without_led( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test the LED-pulse button is not created when hasLED is 0.""" + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(_BUTTON) is None + + +async def test_button_press_when_light_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test pressing the button when the light is off just turns it on.""" + mock_vistapool_client.fetch_pool_data.return_value = _LED_DATA + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: _BUTTON}, + blocking=True, + ) + + mock_vistapool_client.set_value.assert_awaited_once_with( + "ABCDEF1234567890", "light.status", 1 + ) + + +async def test_button_press_when_light_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test pressing the button when the light is on power-cycles it.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"hasLED": 1, "version": 1}, + "light": {"status": 1}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: _BUTTON}, + blocking=True, + ) + + assert mock_vistapool_client.set_value.await_count == 2 + assert mock_vistapool_client.set_value.await_args_list[0].args == ( + "ABCDEF1234567890", + "light.status", + 0, + ) + assert mock_vistapool_client.set_value.await_args_list[1].args == ( + "ABCDEF1234567890", + "light.status", + 1, + ) + + +async def test_button_press_rapid_repeat_after_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test a second press lands the off/on pulse instead of repeating turn-on. + + Without the optimistic update, the second press would read the stale + off-state (the Firestore push hasn't round-tripped yet) and send another + bare light.status=1 — a no-op on the wire that doesn't advance the color. + """ + mock_vistapool_client.fetch_pool_data.return_value = _LED_DATA + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: _BUTTON}, + blocking=True, + ) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: _BUTTON}, + blocking=True, + ) + + assert mock_vistapool_client.set_value.await_count == 3 + assert mock_vistapool_client.set_value.await_args_list[0].args == ( + "ABCDEF1234567890", + "light.status", + 1, + ) + assert mock_vistapool_client.set_value.await_args_list[1].args == ( + "ABCDEF1234567890", + "light.status", + 0, + ) + assert mock_vistapool_client.set_value.await_args_list[2].args == ( + "ABCDEF1234567890", + "light.status", + 1, + ) + + +async def test_button_press_raises_on_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the button re-raises HomeAssistantError when the library fails.""" + mock_vistapool_client.fetch_pool_data.return_value = _LED_DATA + mock_vistapool_client.set_value.side_effect = AquariteError("boom") + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: _BUTTON}, + blocking=True, + ) + assert excinfo.value.translation_key == "set_failed" From 80241a44d9bf12f58727261e49ff024e93b4fe9f Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:02:08 +0800 Subject: [PATCH 7/7] Switchbot Cloud: Enable Webhook for sensor devices (#172814) --- .../components/switchbot_cloud/const.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 8a5fbc7f5632ae..ce0f1dfa07a2e8 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -110,17 +110,15 @@ class SwitchbotCloudDeviceConfig: True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR) ), "Home Climate Panel": SwitchbotCloudDeviceConfig( - False, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR) + True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR) ), "WeatherStation": SwitchbotCloudDeviceConfig( - False, entity_config=(Platform.SENSOR,) - ), - "Meter": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)), - "MeterPlus": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)), - "WoIOSensor": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)), - "Hub 2": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)), - "MeterPro": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)), - "MeterPro(CO2)": SwitchbotCloudDeviceConfig( - False, entity_config=(Platform.SENSOR,) + True, entity_config=(Platform.SENSOR,) ), + "Meter": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "MeterPlus": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "WoIOSensor": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "Hub 2": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "MeterPro": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "MeterPro(CO2)": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), }