From 53cf8628faf62281e7bef7350e0771931762a588 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Sep 2024 20:34:22 +0200 Subject: [PATCH 001/134] Bump version to 2024.10.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c5648a9e096..0bd91a62c34 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 116fc5b74ed..b55956b5555 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0.dev0" +version = "2024.10.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 17e0db9da3c98ea7b61497371947fb3162cf6213 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 26 Sep 2024 13:22:09 -0500 Subject: [PATCH 002/134] Fix ESPHome and VoIP Assist satellite entity names (#126229) Co-authored-by: Paulus Schoutsen --- homeassistant/components/esphome/strings.json | 5 +++++ homeassistant/components/voip/assist_satellite.py | 3 ++- homeassistant/components/voip/strings.json | 10 ---------- tests/components/esphome/test_assist_satellite.py | 1 + tests/components/voip/test_voip.py | 1 + 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 026b2bd0690..ec7e6f674b3 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -59,6 +59,11 @@ } }, "entity": { + "assist_satellite": { + "assist_satellite": { + "name": "[%key:component::assist_satellite::entity_component::_::name%]" + } + }, "binary_sensor": { "assist_in_progress": { "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 6eb1aee209f..5e32585775c 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -21,6 +21,7 @@ from homeassistant.components.assist_satellite import ( AssistSatelliteEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -79,7 +80,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" - _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG _attr_name = None def __init__( diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index 9da7cf7d534..c25c22f3f80 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -10,16 +10,6 @@ } }, "entity": { - "assist_satellite": { - "assist_satellite": { - "state": { - "listening_wake_word": "[%key:component::assist_satellite::entity_component::_::state::listening_wake_word%]", - "listening_command": "[%key:component::assist_satellite::entity_component::_::state::listening_command%]", - "responding": "[%key:component::assist_satellite::entity_component::_::state::responding%]", - "processing": "[%key:component::assist_satellite::entity_component::_::state::processing%]" - } - } - }, "binary_sensor": { "call_in_progress": { "name": "Call in progress" diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index cfa25489013..43ca3c0a341 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -61,6 +61,7 @@ def get_satellite_entity( ) if satellite_entity_id is None: return None + assert satellite_entity_id.endswith("_assist_satellite") component: EntityComponent[AssistSatelliteEntity] = hass.data[ assist_satellite.DOMAIN diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index cf5148e8ba0..a0e032b65cb 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -57,6 +57,7 @@ def async_get_satellite_entity( ) if satellite_entity_id is None: return None + assert not satellite_entity_id.endswith("none") component: EntityComponent[AssistSatelliteEntity] = hass.data[ assist_satellite.DOMAIN From cf6b07630bd2561fce3026005091465434a654cc Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 25 Sep 2024 20:47:40 +0100 Subject: [PATCH 003/134] Deprecate tplink alarm button entities (#126349) Co-authored-by: J. Nick Koston --- .../components/tplink/binary_sensor.py | 1 + homeassistant/components/tplink/button.py | 20 ++- homeassistant/components/tplink/deprecate.py | 111 +++++++++++++ homeassistant/components/tplink/entity.py | 27 ++- homeassistant/components/tplink/number.py | 1 + homeassistant/components/tplink/select.py | 1 + homeassistant/components/tplink/sensor.py | 4 + homeassistant/components/tplink/strings.json | 6 + homeassistant/components/tplink/switch.py | 3 +- tests/components/tplink/__init__.py | 16 ++ tests/components/tplink/test_button.py | 154 +++++++++++++++++- 11 files changed, 330 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/tplink/deprecate.py diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index 97bb794a8f9..0e426161a0c 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -75,6 +75,7 @@ async def async_setup_entry( device = parent_coordinator.device entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, device=device, coordinator=parent_coordinator, feature_type=Feature.Type.BinarySensor, diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 4dcc27858a8..fd2d7fb664f 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -7,11 +7,17 @@ from typing import Final from kasa import Feature -from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.components.button import ( + DOMAIN as BUTTON_DOMAIN, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry +from .deprecate import DeprecatedInfo, async_cleanup_deprecated from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -25,9 +31,19 @@ class TPLinkButtonEntityDescription( BUTTON_DESCRIPTIONS: Final = [ TPLinkButtonEntityDescription( key="test_alarm", + deprecated_info=DeprecatedInfo( + platform=BUTTON_DOMAIN, + new_platform=SIREN_DOMAIN, + breaks_in_ha_version="2025.4.0", + ), ), TPLinkButtonEntityDescription( key="stop_alarm", + deprecated_info=DeprecatedInfo( + platform=BUTTON_DOMAIN, + new_platform=SIREN_DOMAIN, + breaks_in_ha_version="2025.4.0", + ), ), ] @@ -46,6 +62,7 @@ async def async_setup_entry( device = parent_coordinator.device entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, device=device, coordinator=parent_coordinator, feature_type=Feature.Type.Action, @@ -53,6 +70,7 @@ async def async_setup_entry( descriptions=BUTTON_DESCRIPTIONS_MAP, child_coordinators=children_coordinators, ) + async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities) async_add_entities(entities) diff --git a/homeassistant/components/tplink/deprecate.py b/homeassistant/components/tplink/deprecate.py new file mode 100644 index 00000000000..738f3d24c38 --- /dev/null +++ b/homeassistant/components/tplink/deprecate.py @@ -0,0 +1,111 @@ +"""Helper class for deprecating entities.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN + +if TYPE_CHECKING: + from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription + + +@dataclass(slots=True) +class DeprecatedInfo: + """Class to define deprecation info for deprecated entities.""" + + platform: str + new_platform: str + breaks_in_ha_version: str + + +def async_check_create_deprecated( + hass: HomeAssistant, + unique_id: str, + entity_description: TPLinkFeatureEntityDescription, +) -> bool: + """Return true if the entity should be created based on the deprecated_info. + + If deprecated_info is not defined will return true. + If entity not yet created will return false. + If entity disabled will return false. + """ + if not entity_description.deprecated_info: + return True + + deprecated_info = entity_description.deprecated_info + platform = deprecated_info.platform + + ent_reg = er.async_get(hass) + entity_id = ent_reg.async_get_entity_id( + platform, + DOMAIN, + unique_id, + ) + if not entity_id: + return False + + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + return not entity_entry.disabled + + +def async_cleanup_deprecated( + hass: HomeAssistant, + platform: str, + entry_id: str, + entities: Sequence[CoordinatedTPLinkFeatureEntity], +) -> None: + """Remove disabled deprecated entities or create issues if necessary.""" + ent_reg = er.async_get(hass) + for entity in entities: + if not (deprecated_info := entity.entity_description.deprecated_info): + continue + + assert entity.unique_id + entity_id = ent_reg.async_get_entity_id( + platform, + DOMAIN, + entity.unique_id, + ) + assert entity_id + # Check for issues that need to be created + entity_automations = automations_with_entity(hass, entity_id) + entity_scripts = scripts_with_entity(hass, entity_id) + + for item in entity_automations + entity_scripts: + async_create_issue( + hass, + DOMAIN, + f"deprecated_entity_{entity_id}_{item}", + breaks_in_ha_version=deprecated_info.breaks_in_ha_version, + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_entity", + translation_placeholders={ + "entity": entity_id, + "info": item, + "platform": platform, + "new_platform": deprecated_info.new_platform, + }, + ) + + # Remove entities that are no longer provided and have been disabled. + unique_ids = {entity.unique_id for entity in entities} + for entity_entry in er.async_entries_for_config_entry(ent_reg, entry_id): + if ( + entity_entry.domain == platform + and entity_entry.disabled + and entity_entry.unique_id not in unique_ids + ): + ent_reg.async_remove(entity_entry.entity_id) + continue diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 9d357d8a22c..ef9e2ad5eee 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -18,7 +18,7 @@ from kasa import ( ) from homeassistant.const import EntityCategory -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -36,6 +36,7 @@ from .const import ( PRIMARY_STATE_ID, ) from .coordinator import TPLinkDataUpdateCoordinator +from .deprecate import DeprecatedInfo, async_check_create_deprecated _LOGGER = logging.getLogger(__name__) @@ -87,6 +88,8 @@ LEGACY_KEY_MAPPING = { class TPLinkFeatureEntityDescription(EntityDescription): """Base class for a TPLink feature based entity description.""" + deprecated_info: DeprecatedInfo | None = None + def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( func: Callable[Concatenate[_T, _P], Awaitable[None]], @@ -251,18 +254,25 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): def _get_unique_id(self) -> str: """Return unique ID for the entity.""" - key = self.entity_description.key + return self._get_feature_unique_id(self._device, self.entity_description) + + @staticmethod + def _get_feature_unique_id( + device: Device, entity_description: TPLinkFeatureEntityDescription + ) -> str: + """Return unique ID for the entity.""" + key = entity_description.key # The unique id for the state feature in the switch platform is the # device_id if key == PRIMARY_STATE_ID: - return legacy_device_id(self._device) + return legacy_device_id(device) # Historically the legacy device emeter attributes which are now # replaced with features used slightly different keys. This ensures # that those entities are not orphaned. Returns the mapped key or the # provided key if not mapped. key = LEGACY_KEY_MAPPING.get(key, key) - return f"{legacy_device_id(self._device)}_{key}" + return f"{legacy_device_id(device)}_{key}" @classmethod def _category_for_feature(cls, feature: Feature | None) -> EntityCategory | None: @@ -334,6 +344,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): _D: TPLinkFeatureEntityDescription, ]( cls, + hass: HomeAssistant, device: Device, coordinator: TPLinkDataUpdateCoordinator, *, @@ -368,6 +379,11 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): feat, descriptions, device=device, parent=parent ) ) + and async_check_create_deprecated( + hass, + cls._get_feature_unique_id(device, desc), + desc, + ) ] return entities @@ -377,6 +393,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): _D: TPLinkFeatureEntityDescription, ]( cls, + hass: HomeAssistant, device: Device, coordinator: TPLinkDataUpdateCoordinator, *, @@ -393,6 +410,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): # Add parent entities before children so via_device id works. entities.extend( cls._entities_for_device( + hass, device, coordinator=coordinator, feature_type=feature_type, @@ -412,6 +430,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): child_coordinator = coordinator entities.extend( cls._entities_for_device( + hass, child, coordinator=child_coordinator, feature_type=feature_type, diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 999d01b2814..5f80d5479d2 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -67,6 +67,7 @@ async def async_setup_entry( children_coordinators = data.children_coordinators device = parent_coordinator.device entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, device=device, coordinator=parent_coordinator, feature_type=Feature.Type.Number, diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index 41703b27e5a..41e3224215b 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -54,6 +54,7 @@ async def async_setup_entry( device = parent_coordinator.device entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, device=device, coordinator=parent_coordinator, feature_type=Feature.Type.Choice, diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 1307079937f..276334dc8a1 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -8,6 +8,7 @@ from typing import cast from kasa import Feature from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -18,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry from .const import UNIT_MAPPING +from .deprecate import async_cleanup_deprecated from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -128,6 +130,7 @@ async def async_setup_entry( device = parent_coordinator.device entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, device=device, coordinator=parent_coordinator, feature_type=Feature.Type.Sensor, @@ -135,6 +138,7 @@ async def async_setup_entry( descriptions=SENSOR_DESCRIPTIONS_MAP, child_coordinators=children_coordinators, ) + async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities) async_add_entities(entities) diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 34ce96612f5..2afc46a5ff1 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -311,5 +311,11 @@ "device_authentication": { "message": "Device authentication error {func}: {exc}" } + }, + "issues": { + "deprecated_entity": { + "title": "Detected deprecated `{platform}` entity usage", + "description": "We detected that entity `{entity}` is being used in `{info}`\n\nWe have created a new `{new_platform}` entity and you should migrate `{info}` to use this new entity.\n\nWhen you are done migrating `{info}` and are ready to have the deprecated `{entity}` entity removed, disable the entity and restart Home Assistant." + } } } diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 62957d48ac4..6d3e21d88c5 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -64,7 +64,8 @@ async def async_setup_entry( device = parent_coordinator.device entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - device, + hass=hass, + device=device, coordinator=parent_coordinator, feature_type=Feature.Switch, entity_class=TPLinkSwitch, diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 35ca3f2267c..4100d8781d4 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -21,6 +21,7 @@ from kasa.protocol import BaseProtocol from kasa.smart.modules.alarm import Alarm from syrupy import SnapshotAssertion +from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.tplink import ( CONF_AES_KEYS, CONF_ALIAS, @@ -184,6 +185,21 @@ async def snapshot_platform( ), f"state snapshot failed for {entity_entry.entity_id}" +async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> None: + """Set up an automation for tests.""" + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": alias, + "trigger": {"platform": "state", "entity_id": entity_id, "to": "on"}, + "action": {"action": "notify.notify", "metadata": {}, "data": {}}, + } + }, + ) + + def _mock_protocol() -> BaseProtocol: protocol = MagicMock(spec=BaseProtocol) protocol.close = AsyncMock() diff --git a/tests/components/tplink/test_button.py b/tests/components/tplink/test_button.py index 143a882a6cb..2234ce43166 100644 --- a/tests/components/tplink/test_button.py +++ b/tests/components/tplink/test_button.py @@ -11,7 +11,11 @@ from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from . import ( @@ -22,6 +26,7 @@ from . import ( _mocked_strip_children, _patch_connect, _patch_discovery, + setup_automation, setup_platform_for_device, snapshot_platform, ) @@ -29,6 +34,53 @@ from . import ( from tests.common import MockConfigEntry +@pytest.fixture +def create_deprecated_button_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +): + """Create the entity so it is not ignored by the deprecation check.""" + mock_config_entry.add_to_hass(hass) + + def create_entry(device_name, device_id, key): + unique_id = f"{device_id}_{key}" + + entity_registry.async_get_or_create( + domain=BUTTON_DOMAIN, + platform=DOMAIN, + unique_id=unique_id, + suggested_object_id=f"{device_name}_{key}", + config_entry=mock_config_entry, + ) + + create_entry("my_device", "123456789ABCDEFGH", "stop_alarm") + create_entry("my_device", "123456789ABCDEFGH", "test_alarm") + + +@pytest.fixture +def create_deprecated_child_button_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +): + """Create the entity so it is not ignored by the deprecation check.""" + + def create_entry(device_name, key): + for plug_id in range(2): + unique_id = f"PLUG{plug_id}DEVICEID_{key}" + entity_registry.async_get_or_create( + domain=BUTTON_DOMAIN, + platform=DOMAIN, + unique_id=unique_id, + suggested_object_id=f"my_device_plug{plug_id}_{key}", + config_entry=mock_config_entry, + ) + + create_entry("my_device", "stop_alarm") + create_entry("my_device", "test_alarm") + + @pytest.fixture def mocked_feature_button() -> Feature: """Return mocked tplink binary sensor feature.""" @@ -47,6 +99,7 @@ async def test_states( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, + create_deprecated_button_entities, ) -> None: """Test a sensor unique ids.""" features = {description.key for description in BUTTON_DESCRIPTIONS} @@ -66,6 +119,7 @@ async def test_button( hass: HomeAssistant, entity_registry: er.EntityRegistry, mocked_feature_button: Feature, + create_deprecated_button_entities, ) -> None: """Test a sensor unique ids.""" mocked_feature = mocked_feature_button @@ -74,13 +128,13 @@ async def test_button( ) already_migrated_config_entry.add_to_hass(hass) - plug = _mocked_device(alias="my_plug", features=[mocked_feature]) + plug = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() # The entity_id is based on standard name from core. - entity_id = "button.my_plug_test_alarm" + entity_id = "button.my_device_test_alarm" entity = entity_registry.async_get(entity_id) assert entity assert entity.unique_id == f"{DEVICE_ID}_{mocked_feature.id}" @@ -91,6 +145,8 @@ async def test_button_children( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, mocked_feature_button: Feature, + create_deprecated_button_entities, + create_deprecated_child_button_entities, ) -> None: """Test a sensor unique ids.""" mocked_feature = mocked_feature_button @@ -99,7 +155,7 @@ async def test_button_children( ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device( - alias="my_plug", + alias="my_device", features=[mocked_feature], children=_mocked_strip_children(features=[mocked_feature]), ) @@ -107,13 +163,13 @@ async def test_button_children( await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "button.my_plug_test_alarm" + entity_id = "button.my_device_test_alarm" entity = entity_registry.async_get(entity_id) assert entity device = device_registry.async_get(entity.device_id) for plug_id in range(2): - child_entity_id = f"button.my_plug_plug{plug_id}_test_alarm" + child_entity_id = f"button.my_device_plug{plug_id}_test_alarm" child_entity = entity_registry.async_get(child_entity_id) assert child_entity assert child_entity.unique_id == f"PLUG{plug_id}DEVICEID_{mocked_feature.id}" @@ -127,6 +183,7 @@ async def test_button_press( hass: HomeAssistant, entity_registry: er.EntityRegistry, mocked_feature_button: Feature, + create_deprecated_button_entities, ) -> None: """Test a number entity limits and setting values.""" mocked_feature = mocked_feature_button @@ -134,12 +191,12 @@ async def test_button_press( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - plug = _mocked_device(alias="my_plug", features=[mocked_feature]) + plug = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "button.my_plug_test_alarm" + entity_id = "button.my_device_test_alarm" entity = entity_registry.async_get(entity_id) assert entity assert entity.unique_id == f"{DEVICE_ID}_test_alarm" @@ -151,3 +208,84 @@ async def test_button_press( blocking=True, ) mocked_feature.set_value.assert_called_with(True) + + +async def test_button_not_exists_with_deprecation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mocked_feature_button: Feature, +) -> None: + """Test deprecated buttons are not created if they don't previously exist.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + config_entry.add_to_hass(hass) + entity_id = "button.my_device_test_alarm" + + assert not hass.states.get(entity_id) + mocked_feature = mocked_feature_button + dev = _mocked_device(alias="my_device", features=[mocked_feature]) + with _patch_discovery(device=dev), _patch_connect(device=dev): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + assert not entity_registry.async_get(entity_id) + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + assert not hass.states.get(entity_id) + + +@pytest.mark.parametrize( + ("entity_disabled", "entity_has_automations"), + [ + pytest.param(False, False, id="without-automations"), + pytest.param(False, True, id="with-automations"), + pytest.param(True, False, id="disabled"), + ], +) +async def test_button_exists_with_deprecation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mocked_feature_button: Feature, + entity_disabled: bool, + entity_has_automations: bool, +) -> None: + """Test the deprecated buttons are deleted or raise issues.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + config_entry.add_to_hass(hass) + + object_id = "my_device_test_alarm" + entity_id = f"button.{object_id}" + unique_id = f"{DEVICE_ID}_test_alarm" + issue_id = f"deprecated_entity_{entity_id}_automation.test_automation" + + if entity_has_automations: + await setup_automation(hass, "test_automation", entity_id) + + entity = entity_registry.async_get_or_create( + domain=BUTTON_DOMAIN, + platform=DOMAIN, + unique_id=unique_id, + suggested_object_id=object_id, + config_entry=config_entry, + disabled_by=er.RegistryEntryDisabler.USER if entity_disabled else None, + ) + assert entity.entity_id == entity_id + assert not hass.states.get(entity_id) + + mocked_feature = mocked_feature_button + dev = _mocked_device(alias="my_device", features=[mocked_feature]) + with _patch_discovery(device=dev), _patch_connect(device=dev): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity = entity_registry.async_get(entity_id) + # entity and state will be none if removed from registry + assert (entity is None) == entity_disabled + assert (hass.states.get(entity_id) is None) == entity_disabled + + assert ( + issue_registry.async_get_issue(DOMAIN, issue_id) is not None + ) == entity_has_automations From 11cc7182734eced2a63e95faa19fe0c1a2856f87 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 25 Sep 2024 21:16:14 +0200 Subject: [PATCH 004/134] Change Climate set temp action for incorrect feature will raise (#126692) * Change Climate set temp action for incorrect feature will raise * Fix some tests * Fix review comments * Fix tesla_fleet * Fix tests * Fix review comment --- homeassistant/components/climate/__init__.py | 40 ++----------- homeassistant/components/climate/strings.json | 6 ++ tests/components/climate/test_init.py | 60 +++++++++---------- tests/components/deconz/test_climate.py | 2 +- tests/components/esphome/test_climate.py | 52 ++++++++-------- tests/components/fritzbox/test_climate.py | 9 --- .../homematicip_cloud/test_climate.py | 7 --- tests/components/lcn/test_climate.py | 23 +++---- .../maxcube/test_maxcube_climate.py | 2 +- tests/components/shelly/test_climate.py | 28 --------- tests/components/switcher_kis/test_climate.py | 6 +- tests/components/tesla_fleet/test_climate.py | 3 +- tests/components/teslemetry/test_climate.py | 13 ---- 13 files changed, 85 insertions(+), 166 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index cd2ce3b563b..432fbffb843 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -965,46 +965,18 @@ async def async_service_temperature_set( ATTR_TEMPERATURE in service_call.data and not entity.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE ): - # Warning implemented in 2024.10 and will be changed to raising - # a ServiceValidationError in 2025.4 - report_issue = async_suggest_report_issue( - entity.hass, - integration_domain=entity.platform.platform_name, - module=type(entity).__module__, - ) - _LOGGER.warning( - ( - "%s::%s set_temperature action was used with temperature but the entity does not " - "implement the ClimateEntityFeature.TARGET_TEMPERATURE feature. " - "This will stop working in 2025.4 and raise an error instead. " - "Please %s" - ), - entity.platform.platform_name, - entity.__class__.__name__, - report_issue, + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_target_temperature_entity_feature", ) if ( ATTR_TARGET_TEMP_LOW in service_call.data and not entity.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ): - # Warning implemented in 2024.10 and will be changed to raising - # a ServiceValidationError in 2025.4 - report_issue = async_suggest_report_issue( - entity.hass, - integration_domain=entity.platform.platform_name, - module=type(entity).__module__, - ) - _LOGGER.warning( - ( - "%s::%s set_temperature action was used with target_temp_low but the entity does not " - "implement the ClimateEntityFeature.TARGET_TEMPERATURE_RANGE feature. " - "This will stop working in 2025.4 and raise an error instead. " - "Please %s" - ), - entity.platform.platform_name, - entity.__class__.__name__, - report_issue, + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_target_temperature_range_entity_feature", ) hass = entity.hass diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index fc0bdaf0d72..26a06821d84 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -275,6 +275,12 @@ }, "humidity_out_of_range": { "message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}." + }, + "missing_target_temperature_entity_feature": { + "message": "Set temperature action was used with the target temperature parameter but the entity does not support it." + }, + "missing_target_temperature_range_entity_feature": { + "message": "Set temperature action was used with the target temperature low/high parameter but the entity does not support it." } } } diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 2b09c2801df..aa162e0b683 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -290,40 +290,34 @@ async def test_temperature_features_is_valid( await hass.config_entries.async_setup(register_test_integration.entry_id) await hass.async_block_till_done() - await hass.services.async_call( - DOMAIN, - SERVICE_SET_TEMPERATURE, - { - "entity_id": "climate.test_temp", - "temperature": 20, - }, - blocking=True, - ) - assert ( - "MockClimateTempEntity set_temperature action was used " - "with temperature but the entity does not " - "implement the ClimateEntityFeature.TARGET_TEMPERATURE feature. " - "This will stop working in 2025.4 and raise an error instead. " - "Please" - ) in caplog.text + with pytest.raises( + ServiceValidationError, + match="Set temperature action was used with the target temperature parameter but the entity does not support it", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + "entity_id": "climate.test_temp", + "temperature": 20, + }, + blocking=True, + ) - await hass.services.async_call( - DOMAIN, - SERVICE_SET_TEMPERATURE, - { - "entity_id": "climate.test_range", - "target_temp_low": 20, - "target_temp_high": 25, - }, - blocking=True, - ) - assert ( - "MockClimateTempRangeEntity set_temperature action was used with " - "target_temp_low but the entity does not " - "implement the ClimateEntityFeature.TARGET_TEMPERATURE_RANGE feature. " - "This will stop working in 2025.4 and raise an error instead. " - "Please" - ) in caplog.text + with pytest.raises( + ServiceValidationError, + match="Set temperature action was used with the target temperature low/high parameter but the entity does not support it", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + "entity_id": "climate.test_range", + "target_temp_low": 20, + "target_temp_high": 25, + }, + blocking=True, + ) async def test_mode_validation( diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index 7f456e81976..e1000f0b4d6 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -259,7 +259,7 @@ async def test_climate_device_without_cooling_support( # Service set temperature without providing temperature attribute - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 4ec7fee6447..189b86fc5fd 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -13,6 +13,7 @@ from aioesphomeapi import ( ClimateState, ClimateSwingMode, ) +import pytest from syrupy import SnapshotAssertion from homeassistant.components.climate import ( @@ -41,6 +42,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError async def test_climate_entity( @@ -54,7 +56,6 @@ async def test_climate_entity( name="my climate", unique_id="my_climate", supports_current_temperature=True, - supports_two_point_target_temperature=True, supports_action=True, visual_min_temperature=10.0, visual_max_temperature=30.0, @@ -134,14 +135,13 @@ async def test_climate_entity_with_step_and_two_point( assert state is not None assert state.state == HVACMode.COOL - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_TEMPERATURE: 25}, - blocking=True, - ) - mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) - mock_client.climate_command.reset_mock() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_TEMPERATURE: 25}, + blocking=True, + ) await hass.services.async_call( CLIMATE_DOMAIN, @@ -213,38 +213,34 @@ async def test_climate_entity_with_step_and_target_temp( assert state is not None assert state.state == HVACMode.COOL - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_TEMPERATURE: 25}, - blocking=True, - ) - mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) - mock_client.climate_command.reset_mock() - await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_HVAC_MODE: HVACMode.AUTO, - ATTR_TARGET_TEMP_LOW: 20, - ATTR_TARGET_TEMP_HIGH: 30, + ATTR_TEMPERATURE: 25, }, blocking=True, ) mock_client.climate_command.assert_has_calls( - [ - call( - key=1, - mode=ClimateMode.AUTO, - target_temperature_low=20.0, - target_temperature_high=30.0, - ) - ] + [call(key=1, mode=ClimateMode.AUTO, target_temperature=25.0)] ) mock_client.climate_command.reset_mock() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.test_myclimate", + ATTR_HVAC_MODE: HVACMode.AUTO, + ATTR_TARGET_TEMP_LOW: 20, + ATTR_TARGET_TEMP_HIGH: 30, + }, + blocking=True, + ) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index f43e77e9861..61fe6b48a7a 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -15,8 +15,6 @@ from homeassistant.components.climate import ( ATTR_MIN_TEMP, ATTR_PRESET_MODE, ATTR_PRESET_MODES, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, PRESET_COMFORT, PRESET_ECO, @@ -290,13 +288,6 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: }, [call(23)], ), - ( - { - ATTR_TARGET_TEMP_HIGH: 16, - ATTR_TARGET_TEMP_LOW: 10, - }, - [], - ), ], ) async def test_set_temperature( diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index c059ed4b744..d4711440288 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -141,13 +141,6 @@ async def test_hmip_heating_group_heat( ha_state = hass.states.get(entity_id) assert ha_state.attributes[ATTR_PRESET_MODE] == "STD" - # Not required for hmip, but a possibility to send no temperature. - await hass.services.async_call( - "climate", - "set_temperature", - {"entity_id": entity_id, "target_temp_low": 10, "target_temp_high": 10}, - blocking=True, - ) # No new service call should be in mock_calls. assert len(hmip_device.mock_calls) == service_call_counter + 12 # Only fire event from last async_manipulate_test_data available. diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index b7fcc2fbe4b..7ba263bd597 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -5,6 +5,7 @@ from unittest.mock import patch from pypck.inputs import ModStatusVar, Unknown from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import Var, VarUnit, VarValue +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( @@ -25,6 +26,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from .conftest import MockConfigEntry, MockModuleConnection, init_integration @@ -140,16 +142,17 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N # wrong temperature set via service call with high/low attributes var_abs.return_value = False - await hass.services.async_call( - DOMAIN_CLIMATE, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: "climate.climate1", - ATTR_TARGET_TEMP_LOW: 24.5, - ATTR_TARGET_TEMP_HIGH: 25.5, - }, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN_CLIMATE, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.climate1", + ATTR_TARGET_TEMP_LOW: 24.5, + ATTR_TARGET_TEMP_HIGH: 25.5, + }, + blocking=True, + ) var_abs.assert_not_awaited() diff --git a/tests/components/maxcube/test_maxcube_climate.py b/tests/components/maxcube/test_maxcube_climate.py index 48e616f8fd2..8b56ee6a6de 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -216,7 +216,7 @@ async def test_thermostat_set_no_temperature( hass: HomeAssistant, cube: MaxCube, thermostat: MaxThermostat ) -> None: """Set hvac mode to heat.""" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 997cf945626..aeeeca30edd 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -13,8 +13,6 @@ from homeassistant.components.climate import ( ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_PRESET_MODE, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, PRESET_NONE, SERVICE_SET_HVAC_MODE, @@ -138,19 +136,6 @@ async def test_climate_set_temperature( assert state.state == HVACMode.OFF assert state.attributes[ATTR_TEMPERATURE] == 4 - # Test set temperature without target temperature - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: ENTITY_ID, - ATTR_TARGET_TEMP_LOW: 20, - ATTR_TARGET_TEMP_HIGH: 30, - }, - blocking=True, - ) - mock_block_device.http_request.assert_not_called() - # Test set temperature await hass.services.async_call( CLIMATE_DOMAIN, @@ -684,19 +669,6 @@ async def test_rpc_climate_set_temperature( state = hass.states.get(entity_id) assert state.attributes[ATTR_TEMPERATURE] == 23 - # test set temperature without target temperature - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TARGET_TEMP_LOW: 20, - ATTR_TARGET_TEMP_HIGH: 30, - }, - blocking=True, - ) - mock_rpc_device.call_rpc.assert_not_called() - monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "target_C", 28) await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/switcher_kis/test_climate.py b/tests/components/switcher_kis/test_climate.py index 5da9684bf2a..c9f7abf34dc 100644 --- a/tests/components/switcher_kis/test_climate.py +++ b/tests/components/switcher_kis/test_climate.py @@ -98,6 +98,10 @@ async def test_climate_temperature( await init_integration(hass) assert mock_bridge + monkeypatch.setattr(DEVICE, "mode", ThermostatMode.HEAT) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + # Test initial target temperature state = hass.states.get(ENTITY_ID) assert state.attributes["temperature"] == 23 @@ -126,7 +130,7 @@ async def test_climate_temperature( with patch( "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", ) as mock_control_device: - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, diff --git a/tests/components/tesla_fleet/test_climate.py b/tests/components/tesla_fleet/test_climate.py index 75474698d09..b8cb7f1269b 100644 --- a/tests/components/tesla_fleet/test_climate.py +++ b/tests/components/tesla_fleet/test_climate.py @@ -436,7 +436,8 @@ async def test_climate_notemp( await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) with pytest.raises( - ServiceValidationError, match="Temperature is required for this action" + ServiceValidationError, + match="Set temperature action was used with the target temperature low/high parameter but the entity does not support it", ): await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 3cb4b67dc54..800748f4c77 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -10,8 +10,6 @@ from tesla_fleet_api.exceptions import InvalidCommand, VehicleOffline from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_PRESET_MODE, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, ATTR_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -175,17 +173,6 @@ async def test_climate( state = hass.states.get(entity_id) assert state.state == HVACMode.COOL - # Set Temp do nothing - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: [entity_id], - ATTR_TARGET_TEMP_HIGH: 30, - ATTR_TARGET_TEMP_LOW: 30, - }, - blocking=True, - ) state = hass.states.get(entity_id) assert state.attributes[ATTR_TEMPERATURE] == 40 assert state.state == HVACMode.COOL From 638dd375455002f9b1912a0b22e98b27c789998a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 25 Sep 2024 21:52:26 +0200 Subject: [PATCH 005/134] Remove Reolink Home Hub main level switches (#126697) Co-authored-by: Robert Resch --- homeassistant/components/reolink/strings.json | 4 + homeassistant/components/reolink/switch.py | 88 ++++++++++- tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_switch.py | 146 +++++++++++++++++- 4 files changed, 231 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 6dde5efa2ec..4ec4dcffdfd 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -83,6 +83,10 @@ "hdr_switch_deprecated": { "title": "Reolink HDR switch deprecated", "description": "The Reolink HDR switch entity is deprecated and will be removed in HA 2025.2.0. It has been replaced by a HDR select entity offering options `on`, `off` and `auto`. To remove this issue, please adjust automations accordingly and disable the HDR switch entity." + }, + "hub_switch_deprecated": { + "title": "Reolink Home Hub switches deprecated", + "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are depricated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned." } }, "services": { diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 162679965fb..482cdab18a7 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -214,7 +214,7 @@ NVR_SWITCH_ENTITIES = ( cmd_key="GetEmail", translation_key="email", entity_category=EntityCategory.CONFIG, - supported=lambda api: api.supported(None, "email"), + supported=lambda api: api.supported(None, "email") and not api.is_hub, value=lambda api: api.email_enabled(), method=lambda api, value: api.set_email(None, value), ), @@ -223,7 +223,7 @@ NVR_SWITCH_ENTITIES = ( cmd_key="GetFtp", translation_key="ftp_upload", entity_category=EntityCategory.CONFIG, - supported=lambda api: api.supported(None, "ftp"), + supported=lambda api: api.supported(None, "ftp") and not api.is_hub, value=lambda api: api.ftp_enabled(), method=lambda api, value: api.set_ftp(None, value), ), @@ -232,7 +232,7 @@ NVR_SWITCH_ENTITIES = ( cmd_key="GetPush", translation_key="push_notifications", entity_category=EntityCategory.CONFIG, - supported=lambda api: api.supported(None, "push"), + supported=lambda api: api.supported(None, "push") and not api.is_hub, value=lambda api: api.push_enabled(), method=lambda api, value: api.set_push(None, value), ), @@ -241,7 +241,7 @@ NVR_SWITCH_ENTITIES = ( cmd_key="GetRec", translation_key="record", entity_category=EntityCategory.CONFIG, - supported=lambda api: api.supported(None, "recording"), + supported=lambda api: api.supported(None, "recording") and not api.is_hub, value=lambda api: api.recording_enabled(), method=lambda api, value: api.set_recording(None, value), ), @@ -250,7 +250,7 @@ NVR_SWITCH_ENTITIES = ( cmd_key="GetBuzzerAlarmV20", translation_key="hub_ringtone_on_event", entity_category=EntityCategory.CONFIG, - supported=lambda api: api.supported(None, "buzzer"), + supported=lambda api: api.supported(None, "buzzer") and not api.is_hub, value=lambda api: api.buzzer_enabled(), method=lambda api, value: api.set_buzzer(None, value), ), @@ -279,6 +279,56 @@ DEPRECATED_HDR = ReolinkSwitchEntityDescription( method=lambda api, ch, value: api.set_HDR(ch, value), ) +# Can be removed in HA 2025.4.0 +DEPRECATED_NVR_SWITCHES = [ + ReolinkNVRSwitchEntityDescription( + key="email", + cmd_key="GetEmail", + translation_key="email", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.is_hub, + value=lambda api: api.email_enabled(), + method=lambda api, value: api.set_email(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="ftp_upload", + cmd_key="GetFtp", + translation_key="ftp_upload", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.is_hub, + value=lambda api: api.ftp_enabled(), + method=lambda api, value: api.set_ftp(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="push_notifications", + cmd_key="GetPush", + translation_key="push_notifications", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.is_hub, + value=lambda api: api.push_enabled(), + method=lambda api, value: api.set_push(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="record", + cmd_key="GetRec", + translation_key="record", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.is_hub, + value=lambda api: api.recording_enabled(), + method=lambda api, value: api.set_recording(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="buzzer", + cmd_key="GetBuzzerAlarmV20", + translation_key="hub_ringtone_on_event", + icon="mdi:room-service", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.is_hub, + value=lambda api: api.buzzer_enabled(), + method=lambda api, value: api.set_buzzer(None, value), + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -307,10 +357,17 @@ async def async_setup_entry( for chime in reolink_data.host.api.chime_list ) - # Can be removed in HA 2025.2.0 + # Can be removed in HA 2025.4.0 + depricated_dict = {} + for desc in DEPRECATED_NVR_SWITCHES: + if not desc.supported(reolink_data.host.api): + continue + depricated_dict[f"{reolink_data.host.unique_id}_{desc.key}"] = desc + entity_reg = er.async_get(hass) reg_entities = er.async_entries_for_config_entry(entity_reg, config_entry.entry_id) for entity in reg_entities: + # Can be removed in HA 2025.2.0 if entity.domain == "switch" and entity.unique_id.endswith("_hdr"): if entity.disabled: entity_reg.async_remove(entity.entity_id) @@ -329,7 +386,24 @@ async def async_setup_entry( for channel in reolink_data.host.api.channels if DEPRECATED_HDR.supported(reolink_data.host.api, channel) ) - break + + # Can be removed in HA 2025.4.0 + if entity.domain == "switch" and entity.unique_id in depricated_dict: + if entity.disabled: + entity_reg.async_remove(entity.entity_id) + continue + + ir.async_create_issue( + hass, + DOMAIN, + "hub_switch_deprecated", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="hub_switch_deprecated", + ) + entities.append( + ReolinkNVRSwitchEntity(reolink_data, depricated_dict[entity.unique_id]) + ) async_add_entities(entities) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 720ee362c3c..458bac5022b 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -66,6 +66,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.check_new_firmware.return_value = False host_mock.unsubscribe.return_value = True host_mock.logout.return_value = True + host_mock.is_hub = False host_mock.mac_address = TEST_MAC host_mock.uid = TEST_UID host_mock.onvif_enabled = True diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index f9fb18a458f..142075ca0b0 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -28,7 +28,7 @@ from .conftest import TEST_CAM_NAME, TEST_NVR_NAME, TEST_UID from tests.common import MockConfigEntry, async_fire_time_changed -async def test_cleanup_hdr_switch_( +async def test_cleanup_hdr_switch( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, @@ -60,6 +60,77 @@ async def test_cleanup_hdr_switch_( assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None +@pytest.mark.parametrize( + ( + "original_id", + "capability", + ), + [ + ( + f"{TEST_UID}_record", + "recording", + ), + ( + f"{TEST_UID}_ftp_upload", + "ftp", + ), + ( + f"{TEST_UID}_push_notifications", + "push", + ), + ( + f"{TEST_UID}_email", + "email", + ), + ( + f"{TEST_UID}_buzzer", + "buzzer", + ), + ], +) +async def test_cleanup_hub_switches( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, + original_id: str, + capability: str, +) -> None: + """Test entity ids that need to be migrated.""" + + def mock_supported(ch, cap): + if cap == capability: + return False + return True + + domain = Platform.SWITCH + + reolink_connect.channels = [0] + reolink_connect.is_hub = True + reolink_connect.supported = mock_supported + + entity_registry.async_get_or_create( + domain=domain, + platform=DOMAIN, + unique_id=original_id, + config_entry=config_entry, + suggested_object_id=original_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) + + # setup CH 0 and host entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [domain]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None + + reolink_connect.is_hub = False + reolink_connect.supported.return_value = True + + async def test_hdr_switch_deprecated_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -95,6 +166,79 @@ async def test_hdr_switch_deprecated_repair_issue( assert (DOMAIN, "hdr_switch_deprecated") in issue_registry.issues +@pytest.mark.parametrize( + ( + "original_id", + "capability", + ), + [ + ( + f"{TEST_UID}_record", + "recording", + ), + ( + f"{TEST_UID}_ftp_upload", + "ftp", + ), + ( + f"{TEST_UID}_push_notifications", + "push", + ), + ( + f"{TEST_UID}_email", + "email", + ), + ( + f"{TEST_UID}_buzzer", + "buzzer", + ), + ], +) +async def test_hub_switches_repair_issue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + original_id: str, + capability: str, +) -> None: + """Test entity ids that need to be migrated.""" + + def mock_supported(ch, cap): + if cap == capability: + return False + return True + + domain = Platform.SWITCH + + reolink_connect.channels = [0] + reolink_connect.is_hub = True + reolink_connect.supported = mock_supported + + entity_registry.async_get_or_create( + domain=domain, + platform=DOMAIN, + unique_id=original_id, + config_entry=config_entry, + suggested_object_id=original_id, + disabled_by=None, + ) + + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) + + # setup CH 0 and host entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [domain]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) + assert (DOMAIN, "hub_switch_deprecated") in issue_registry.issues + + reolink_connect.is_hub = False + reolink_connect.supported.return_value = True + + async def test_switch( hass: HomeAssistant, config_entry: MockConfigEntry, From 9bf0b5bff1d75698dee54bbb48dd6e73e3cd3255 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Thu, 26 Sep 2024 08:38:36 -0400 Subject: [PATCH 006/134] Bump aiorussound to 4.0.5 (#126774) * Bump aiorussound to 4.0.4 * Remove unnecessary exception * Bump aiorussound to 4.0.5 * Fixes * Update homeassistant/components/russound_rio/media_player.py --------- Co-authored-by: Joost Lekkerkerker --- .../components/russound_rio/__init__.py | 33 +++---- .../components/russound_rio/config_flow.py | 59 ++++-------- .../components/russound_rio/const.py | 4 - .../components/russound_rio/entity.py | 33 ++++--- .../components/russound_rio/manifest.json | 2 +- .../components/russound_rio/media_player.py | 91 +++++++------------ .../components/russound_rio/strings.json | 7 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/russound_rio/conftest.py | 2 +- .../russound_rio/test_config_flow.py | 47 +--------- 11 files changed, 90 insertions(+), 192 deletions(-) diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index 823d0736037..ba53f6794e3 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -4,10 +4,11 @@ import asyncio import logging from aiorussound import RussoundClient, RussoundTcpConnectionHandler +from aiorussound.models import CallbackType from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import CONNECT_TIMEOUT, RUSSOUND_RIO_EXCEPTIONS @@ -24,26 +25,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] - russ = RussoundClient(RussoundTcpConnectionHandler(hass.loop, host, port)) + client = RussoundClient(RussoundTcpConnectionHandler(host, port)) - @callback - def is_connected_updated(connected: bool) -> None: - if connected: - _LOGGER.warning("Reconnected to controller at %s:%s", host, port) - else: - _LOGGER.warning( - "Disconnected from controller at %s:%s", - host, - port, - ) + async def _connection_update_callback( + _client: RussoundClient, _callback_type: CallbackType + ) -> None: + """Call when the device is notified of changes.""" + if _callback_type == CallbackType.CONNECTION: + if _client.is_connected(): + _LOGGER.warning("Reconnected to device at %s", entry.data[CONF_HOST]) + else: + _LOGGER.warning("Disconnected from device at %s", entry.data[CONF_HOST]) + + await client.register_state_update_callbacks(_connection_update_callback) - russ.connection_handler.add_connection_callback(is_connected_updated) try: async with asyncio.timeout(CONNECT_TIMEOUT): - await russ.connect() + await client.connect() except RUSSOUND_RIO_EXCEPTIONS as err: raise ConfigEntryNotReady(f"Error while connecting to {host}:{port}") from err - entry.runtime_data = russ + entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -53,6 +54,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await entry.runtime_data.close() + await entry.runtime_data.disconnect() return unload_ok diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py index 03e32f39c08..15d002b3f49 100644 --- a/homeassistant/components/russound_rio/config_flow.py +++ b/homeassistant/components/russound_rio/config_flow.py @@ -6,19 +6,14 @@ import asyncio import logging from typing import Any -from aiorussound import Controller, RussoundClient, RussoundTcpConnectionHandler +from aiorussound import RussoundClient, RussoundTcpConnectionHandler import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers import config_validation as cv -from .const import ( - CONNECT_TIMEOUT, - DOMAIN, - RUSSOUND_RIO_EXCEPTIONS, - NoPrimaryControllerException, -) +from .const import CONNECT_TIMEOUT, DOMAIN, RUSSOUND_RIO_EXCEPTIONS DATA_SCHEMA = vol.Schema( { @@ -30,16 +25,6 @@ DATA_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__name__) -def find_primary_controller_metadata( - controllers: dict[int, Controller], -) -> tuple[str, str]: - """Find the mac address of the primary Russound controller.""" - if 1 in controllers: - c = controllers[1] - return c.mac_address, c.controller_type - raise NoPrimaryControllerException - - class FlowHandler(ConfigFlow, domain=DOMAIN): """Russound RIO configuration flow.""" @@ -54,28 +39,22 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): host = user_input[CONF_HOST] port = user_input[CONF_PORT] - russ = RussoundClient( - RussoundTcpConnectionHandler(self.hass.loop, host, port) - ) + client = RussoundClient(RussoundTcpConnectionHandler(host, port)) try: async with asyncio.timeout(CONNECT_TIMEOUT): - await russ.connect() - controllers = await russ.enumerate_controllers() - metadata = find_primary_controller_metadata(controllers) - await russ.close() + await client.connect() + controller = client.controllers[1] + await client.disconnect() except RUSSOUND_RIO_EXCEPTIONS: _LOGGER.exception("Could not connect to Russound RIO") errors["base"] = "cannot_connect" - except NoPrimaryControllerException: - _LOGGER.exception( - "Russound RIO device doesn't have a primary controller", - ) - errors["base"] = "no_primary_controller" else: - await self.async_set_unique_id(metadata[0]) + await self.async_set_unique_id(controller.mac_address) self._abort_if_unique_id_configured() data = {CONF_HOST: host, CONF_PORT: port} - return self.async_create_entry(title=metadata[1], data=data) + return self.async_create_entry( + title=controller.controller_type, data=data + ) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors @@ -88,25 +67,19 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): port = import_data.get(CONF_PORT, 9621) # Connection logic is repeated here since this method will be removed in future releases - russ = RussoundClient(RussoundTcpConnectionHandler(self.hass.loop, host, port)) + client = RussoundClient(RussoundTcpConnectionHandler(host, port)) try: async with asyncio.timeout(CONNECT_TIMEOUT): - await russ.connect() - controllers = await russ.enumerate_controllers() - metadata = find_primary_controller_metadata(controllers) - await russ.close() + await client.connect() + controller = client.controllers[1] + await client.disconnect() except RUSSOUND_RIO_EXCEPTIONS: _LOGGER.exception("Could not connect to Russound RIO") return self.async_abort( reason="cannot_connect", description_placeholders={} ) - except NoPrimaryControllerException: - _LOGGER.exception("Russound RIO device doesn't have a primary controller") - return self.async_abort( - reason="no_primary_controller", description_placeholders={} - ) else: - await self.async_set_unique_id(metadata[0]) + await self.async_set_unique_id(controller.mac_address) self._abort_if_unique_id_configured() data = {CONF_HOST: host, CONF_PORT: port} - return self.async_create_entry(title=metadata[1], data=data) + return self.async_create_entry(title=controller.controller_type, data=data) diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py index 42a1db5f2ad..1b38dc8ce5c 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -17,10 +17,6 @@ RUSSOUND_RIO_EXCEPTIONS = ( ) -class NoPrimaryControllerException(Exception): - """Thrown when the Russound device is not the primary unit in the RNET stack.""" - - CONNECT_TIMEOUT = 5 MP_FEATURES_BY_FLAG = { diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 292e14e3d6d..23b196ecb2f 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -4,9 +4,9 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate -from aiorussound import Controller, RussoundTcpConnectionHandler +from aiorussound import Controller, RussoundClient, RussoundTcpConnectionHandler +from aiorussound.models import CallbackType -from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity @@ -46,7 +46,7 @@ class RussoundBaseEntity(Entity): self._client = controller.client self._controller = controller self._primary_mac_address = ( - controller.mac_address or controller.parent_controller.mac_address + controller.mac_address or self._client.controllers[1].mac_address ) self._device_identifier = ( self._controller.mac_address @@ -64,30 +64,33 @@ class RussoundBaseEntity(Entity): self._attr_device_info["configuration_url"] = ( f"http://{self._client.connection_handler.host}" ) - if controller.parent_controller: + if controller.controller_id != 1: + assert self._client.controllers[1].mac_address self._attr_device_info["via_device"] = ( DOMAIN, - controller.parent_controller.mac_address, + self._client.controllers[1].mac_address, ) else: + assert controller.mac_address self._attr_device_info["connections"] = { (CONNECTION_NETWORK_MAC, controller.mac_address) } - @callback - def _is_connected_updated(self, connected: bool) -> None: - """Update the state when the device is ready to receive commands or is unavailable.""" - self._attr_available = connected + async def _state_update_callback( + self, _client: RussoundClient, _callback_type: CallbackType + ) -> None: + """Call when the device is notified of changes.""" + if _callback_type == CallbackType.CONNECTION: + self._attr_available = _client.is_connected() + self._controller = _client.controllers[self._controller.controller_id] self.async_write_ha_state() async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self._client.connection_handler.add_connection_callback( - self._is_connected_updated - ) + """Register callback handlers.""" + await self._client.register_state_update_callbacks(self._state_update_callback) async def async_will_remove_from_hass(self) -> None: """Remove callbacks.""" - self._client.connection_handler.remove_connection_callback( - self._is_connected_updated + await self._client.unregister_state_update_callbacks( + self._state_update_callback ) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 55b88c33c45..96fc0fb53db 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==3.1.5"] + "requirements": ["aiorussound==4.0.5"] } diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 2a2b951cf2b..316e4d2be7c 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -4,8 +4,9 @@ from __future__ import annotations import logging -from aiorussound import RussoundClient, Source, Zone -from aiorussound.models import CallbackType +from aiorussound import Controller +from aiorussound.models import Source +from aiorussound.rio import ZoneControlSurface from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -15,8 +16,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -83,31 +83,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Russound RIO platform.""" - russ = entry.runtime_data + client = entry.runtime_data + sources = client.sources - await russ.init_sources() - sources = russ.sources - for source in sources.values(): - await source.watch() - - # Discover controllers - controllers = await russ.enumerate_controllers() - - entities = [] - for controller in controllers.values(): - for zone in controller.zones.values(): - await zone.watch() - mp = RussoundZoneDevice(zone, sources) - entities.append(mp) - - @callback - def on_stop(event): - """Shutdown cleanly when hass stops.""" - hass.loop.create_task(russ.close()) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_stop) - - async_add_entities(entities) + async_add_entities( + RussoundZoneDevice(controller, zone_id, sources) + for controller in client.controllers.values() + for zone_id in controller.zones + ) class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): @@ -123,42 +106,32 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, zone: Zone, sources: dict[int, Source]) -> None: + def __init__( + self, controller: Controller, zone_id: int, sources: dict[int, Source] + ) -> None: """Initialize the zone device.""" - super().__init__(zone.controller) - self._zone = zone + super().__init__(controller) + self._zone_id = zone_id + _zone = self._zone self._sources = sources - self._attr_name = zone.name - self._attr_unique_id = f"{self._primary_mac_address}-{zone.device_str()}" + self._attr_name = _zone.name + self._attr_unique_id = f"{self._primary_mac_address}-{_zone.device_str}" for flag, feature in MP_FEATURES_BY_FLAG.items(): - if flag in zone.client.supported_features: + if flag in self._client.supported_features: self._attr_supported_features |= feature - async def _state_update_callback( - self, _client: RussoundClient, _callback_type: CallbackType - ) -> None: - """Call when the device is notified of changes.""" - self.async_write_ha_state() + @property + def _zone(self) -> ZoneControlSurface: + return self._controller.zones[self._zone_id] - async def async_added_to_hass(self) -> None: - """Register callback handlers.""" - await super().async_added_to_hass() - await self._client.register_state_update_callbacks(self._state_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Remove callbacks.""" - await super().async_will_remove_from_hass() - await self._client.unregister_state_update_callbacks( - self._state_update_callback - ) - - def _current_source(self) -> Source: + @property + def _source(self) -> Source: return self._zone.fetch_current_source() @property def state(self) -> MediaPlayerState | None: """Return the state of the device.""" - status = self._zone.properties.status + status = self._zone.status if status == "ON": return MediaPlayerState.ON if status == "OFF": @@ -168,7 +141,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): @property def source(self): """Get the currently selected source.""" - return self._current_source().name + return self._source.name @property def source_list(self): @@ -178,22 +151,22 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): @property def media_title(self): """Title of current playing media.""" - return self._current_source().properties.song_name + return self._source.song_name @property def media_artist(self): """Artist of current playing media, music track only.""" - return self._current_source().properties.artist_name + return self._source.artist_name @property def media_album_name(self): """Album name of current playing media, music track only.""" - return self._current_source().properties.album_name + return self._source.album_name @property def media_image_url(self): """Image url of current playing media.""" - return self._current_source().properties.cover_art_url + return self._source.cover_art_url @property def volume_level(self): @@ -202,7 +175,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): Value is returned based on a range (0..50). Therefore float divide by 50 to get to the required range. """ - return float(self._zone.properties.volume or "0") / 50.0 + return float(self._zone.volume or "0") / 50.0 @command async def async_turn_off(self) -> None: diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index a8b89e3dae3..c105dcafae2 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -1,7 +1,6 @@ { "common": { - "error_cannot_connect": "Failed to connect to Russound device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect.", - "error_no_primary_controller": "No primary controller was detected for the Russound device. Please make sure that the target Russound device has it's controller ID set to 1 (using the selector on the back of the unit)." + "error_cannot_connect": "Failed to connect to Russound device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect." }, "config": { "step": { @@ -14,12 +13,10 @@ } }, "error": { - "cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]", - "no_primary_controller": "[%key:component::russound_rio::common::error_no_primary_controller%]" + "cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]" }, "abort": { "cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]", - "no_primary_controller": "[%key:component::russound_rio::common::error_no_primary_controller%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, diff --git a/requirements_all.txt b/requirements_all.txt index 2b10caf2a93..78260b517c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,7 +356,7 @@ aioridwell==2024.01.0 aioruckus==0.41 # homeassistant.components.russound_rio -aiorussound==3.1.5 +aiorussound==4.0.5 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d8a2691102..c78a29e4f88 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -338,7 +338,7 @@ aioridwell==2024.01.0 aioruckus==0.41 # homeassistant.components.russound_rio -aiorussound==3.1.5 +aiorussound==4.0.5 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 344c743d0b3..91d009f13f4 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -44,5 +44,5 @@ def mock_russound() -> Generator[AsyncMock]: return_value=mock_client, ), ): - mock_client.enumerate_controllers.return_value = MOCK_CONTROLLERS + mock_client.controllers = MOCK_CONTROLLERS yield mock_client diff --git a/tests/components/russound_rio/test_config_flow.py b/tests/components/russound_rio/test_config_flow.py index 8bc7bd738a1..9461fe1d5be 100644 --- a/tests/components/russound_rio/test_config_flow.py +++ b/tests/components/russound_rio/test_config_flow.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import MOCK_CONFIG, MOCK_CONTROLLERS, MODEL +from .const import MOCK_CONFIG, MODEL async def test_form( @@ -60,37 +60,6 @@ async def test_form_cannot_connect( assert len(mock_setup_entry.mock_calls) == 1 -async def test_no_primary_controller( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock -) -> None: - """Test we handle no primary controller error.""" - mock_russound.enumerate_controllers.return_value = {} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - user_input = MOCK_CONFIG - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "no_primary_controller"} - - # Recover with correct information - mock_russound.enumerate_controllers.return_value = MOCK_CONTROLLERS - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == MODEL - assert result["data"] == MOCK_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_import( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock ) -> None: @@ -119,17 +88,3 @@ async def test_import_cannot_connect( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" - - -async def test_import_no_primary_controller( - hass: HomeAssistant, mock_russound: AsyncMock -) -> None: - """Test import with no primary controller error.""" - mock_russound.enumerate_controllers.return_value = {} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_primary_controller" From eb763563f27e0b54da8c20c98ae15a9b097ff811 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 25 Sep 2024 21:43:20 +0200 Subject: [PATCH 007/134] Bump reolink-aio to 0.9.11 (#126778) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index d4ccaaef134..9e05cf7431e 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.9.10"] + "requirements": ["reolink-aio==0.9.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 78260b517c5..1cac042c7e4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2534,7 +2534,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.9.10 +reolink-aio==0.9.11 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c78a29e4f88..3ef9282f434 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2019,7 +2019,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.9.10 +reolink-aio==0.9.11 # homeassistant.components.rflink rflink==0.0.66 From a435095e7638a38c0f0cbbbf77beed36364c6970 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 26 Sep 2024 07:37:49 +0200 Subject: [PATCH 008/134] Fix missing template alarm control panel menu string (#126791) --- homeassistant/components/template/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 26a6ba61704..0b20ab2f3a3 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -103,6 +103,7 @@ "user": { "description": "This helper allows you to create helper entities that define their state using a template.", "menu_options": { + "alarm_control_panel": "Template an alarm control panel", "binary_sensor": "Template a binary sensor", "button": "Template a button", "image": "Template a image", From 7ab93a70dc81956d3cd7a33ba1a21253f3c869ab Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:00:31 +0100 Subject: [PATCH 009/134] Bump ring-doorbell to 0.9.6 (#126817) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 78195cccfe6..35a1fb84caa 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -14,5 +14,5 @@ "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], "quality_scale": "silver", - "requirements": ["ring-doorbell[listen]==0.9.5"] + "requirements": ["ring-doorbell==0.9.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1cac042c7e4..22ce33c9d00 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2543,7 +2543,7 @@ rfk101py==0.0.1 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell[listen]==0.9.5 +ring-doorbell==0.9.6 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ef9282f434..4ffdca82f12 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2025,7 +2025,7 @@ reolink-aio==0.9.11 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell[listen]==0.9.5 +ring-doorbell==0.9.6 # homeassistant.components.roku rokuecp==0.19.3 From 9d48c77861c26740a37719b1e56e48bfc9b78e14 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Sep 2024 17:31:09 +0200 Subject: [PATCH 010/134] Bump jaraco.abode to 6.2.1 (#126823) --- homeassistant/components/abode/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index be705238932..9f5806d544a 100644 --- a/homeassistant/components/abode/manifest.json +++ b/homeassistant/components/abode/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_push", "loggers": ["jaraco.abode", "lomond"], - "requirements": ["jaraco.abode==6.2.0"] + "requirements": ["jaraco.abode==6.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22ce33c9d00..258f3965fc6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1212,7 +1212,7 @@ ismartgate==5.0.1 israel-rail-api==0.1.2 # homeassistant.components.abode -jaraco.abode==6.2.0 +jaraco.abode==6.2.1 # homeassistant.components.jellyfin jellyfin-apiclient-python==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ffdca82f12..14fe8f4baa7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1020,7 +1020,7 @@ ismartgate==5.0.1 israel-rail-api==0.1.2 # homeassistant.components.abode -jaraco.abode==6.2.0 +jaraco.abode==6.2.1 # homeassistant.components.jellyfin jellyfin-apiclient-python==1.9.2 From 20be8fd2d31d63c40ce9fdd0d9489806c23ca348 Mon Sep 17 00:00:00 2001 From: Manu Date: Thu, 26 Sep 2024 14:28:57 +0200 Subject: [PATCH 011/134] Fix typo in Mealie integration (#126824) --- homeassistant/components/mealie/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 785dd98fea6..72f2d769dd2 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -110,7 +110,7 @@ "services": { "get_mealplan": { "name": "Get mealplan", - "description": "Get meaplan from Mealie", + "description": "Get mealplan from Mealie", "fields": { "config_entry_id": { "name": "Mealie instance", From 9d6569d51539a0c27222e6f074f15877b2629c42 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Sep 2024 15:45:20 +0200 Subject: [PATCH 012/134] Bump knocki to 0.3.5 (#126826) --- homeassistant/components/knocki/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knocki/manifest.json b/homeassistant/components/knocki/manifest.json index fb751d90cac..d9a45b18f0e 100644 --- a/homeassistant/components/knocki/manifest.json +++ b/homeassistant/components/knocki/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["knocki"], - "requirements": ["knocki==0.3.1"] + "requirements": ["knocki==0.3.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 258f3965fc6..f67bcc08fae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1237,7 +1237,7 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knocki -knocki==0.3.1 +knocki==0.3.5 # homeassistant.components.knx knx-frontend==2024.9.10.221729 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 14fe8f4baa7..5f2a645c552 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1036,7 +1036,7 @@ justnimbus==0.7.4 kegtron-ble==0.4.0 # homeassistant.components.knocki -knocki==0.3.1 +knocki==0.3.5 # homeassistant.components.knx knx-frontend==2024.9.10.221729 From 1380ed7328caa418278236d494e7818bb556967b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Sep 2024 20:33:24 +0200 Subject: [PATCH 013/134] Add logging to NYT Games setup failures (#126832) --- homeassistant/components/nyt_games/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nyt_games/config_flow.py b/homeassistant/components/nyt_games/config_flow.py index fceeb5d13f1..03247d6c194 100644 --- a/homeassistant/components/nyt_games/config_flow.py +++ b/homeassistant/components/nyt_games/config_flow.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .const import DOMAIN, LOGGER class NYTGamesConfigFlow(ConfigFlow, domain=DOMAIN): @@ -30,6 +30,7 @@ class NYTGamesConfigFlow(ConfigFlow, domain=DOMAIN): except NYTGamesError: errors["base"] = "cannot_connect" except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") errors["base"] = "unknown" else: await self.async_set_unique_id(str(user_id)) From dd0fc0688d25c0a2cb55a064d603edcf9f24a328 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Sep 2024 18:03:11 +0200 Subject: [PATCH 014/134] Bump nyt_games to 0.4.2 (#126834) * Bump nyt_games to 0.4.1 * Bump nyt_games to 0.4.1 * Bump nyt_games to 0.4.2 --- .../components/nyt_games/coordinator.py | 2 +- .../components/nyt_games/manifest.json | 2 +- homeassistant/components/nyt_games/sensor.py | 10 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../nyt_games/fixtures/new_account.json | 51 +++++++++++++++++++ .../nyt_games/snapshots/test_sensor.ambr | 4 +- tests/components/nyt_games/test_sensor.py | 24 ++++++++- 8 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 tests/components/nyt_games/fixtures/new_account.json diff --git a/homeassistant/components/nyt_games/coordinator.py b/homeassistant/components/nyt_games/coordinator.py index 75aa79f62ba..3b695574750 100644 --- a/homeassistant/components/nyt_games/coordinator.py +++ b/homeassistant/components/nyt_games/coordinator.py @@ -22,7 +22,7 @@ class NYTGamesData: """Class for NYT Games data.""" wordle: Wordle - spelling_bee: SpellingBee + spelling_bee: SpellingBee | None connections: Connections diff --git a/homeassistant/components/nyt_games/manifest.json b/homeassistant/components/nyt_games/manifest.json index 922a29a489b..1cdc5988e38 100644 --- a/homeassistant/components/nyt_games/manifest.json +++ b/homeassistant/components/nyt_games/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nyt_games", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["nyt_games==0.4.0"] + "requirements": ["nyt_games==0.4.2"] } diff --git a/homeassistant/components/nyt_games/sensor.py b/homeassistant/components/nyt_games/sensor.py index 6e243a908b4..6e19a4c21dc 100644 --- a/homeassistant/components/nyt_games/sensor.py +++ b/homeassistant/components/nyt_games/sensor.py @@ -156,10 +156,11 @@ async def async_setup_entry( entities: list[SensorEntity] = [ NYTGamesWordleSensor(coordinator, description) for description in WORDLE_SENSORS ] - entities.extend( - NYTGamesSpellingBeeSensor(coordinator, description) - for description in SPELLING_BEE_SENSORS - ) + if coordinator.data.spelling_bee is not None: + entities.extend( + NYTGamesSpellingBeeSensor(coordinator, description) + for description in SPELLING_BEE_SENSORS + ) entities.extend( NYTGamesConnectionsSensor(coordinator, description) for description in CONNECTIONS_SENSORS @@ -211,6 +212,7 @@ class NYTGamesSpellingBeeSensor(SpellingBeeEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state of the sensor.""" + assert self.coordinator.data.spelling_bee is not None return self.entity_description.value_fn(self.coordinator.data.spelling_bee) diff --git a/requirements_all.txt b/requirements_all.txt index f67bcc08fae..0ed8a3da84e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1484,7 +1484,7 @@ numato-gpio==0.13.0 numpy==1.26.4 # homeassistant.components.nyt_games -nyt_games==0.4.0 +nyt_games==0.4.2 # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f2a645c552..d08378b37cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1232,7 +1232,7 @@ numato-gpio==0.13.0 numpy==1.26.4 # homeassistant.components.nyt_games -nyt_games==0.4.0 +nyt_games==0.4.2 # homeassistant.components.google oauth2client==4.1.3 diff --git a/tests/components/nyt_games/fixtures/new_account.json b/tests/components/nyt_games/fixtures/new_account.json new file mode 100644 index 00000000000..ad4d8e2e416 --- /dev/null +++ b/tests/components/nyt_games/fixtures/new_account.json @@ -0,0 +1,51 @@ +{ + "states": [], + "user_id": 260705259, + "player": { + "user_id": 260705259, + "last_updated": 1727358123, + "stats": { + "wordle": { + "legacyStats": { + "gamesPlayed": 1, + "gamesWon": 1, + "guesses": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 1, + "6": 0, + "fail": 0 + }, + "currentStreak": 0, + "maxStreak": 1, + "lastWonDayOffset": 1118, + "hasPlayed": true, + "autoOptInTimestamp": 1727357874700, + "hasMadeStatsChoice": false, + "timestamp": 1727358123 + }, + "calculatedStats": { + "gamesPlayed": 0, + "gamesWon": 0, + "guesses": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "fail": 0 + }, + "currentStreak": 0, + "maxStreak": 1, + "lastWonPrintDate": "", + "lastCompletedPrintDate": "", + "hasPlayed": false, + "generation": 1 + } + } + } + } +} diff --git a/tests/components/nyt_games/snapshots/test_sensor.ambr b/tests/components/nyt_games/snapshots/test_sensor.ambr index 7c4c2b57253..fdec7d58d9d 100644 --- a/tests/components/nyt_games/snapshots/test_sensor.ambr +++ b/tests/components/nyt_games/snapshots/test_sensor.ambr @@ -547,7 +547,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '33', + 'state': '70', }) # --- # name: test_all_entities[sensor.wordle_won-entry] @@ -597,6 +597,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '26', + 'state': '51', }) # --- diff --git a/tests/components/nyt_games/test_sensor.py b/tests/components/nyt_games/test_sensor.py index 3866b6afab0..f35caf20b57 100644 --- a/tests/components/nyt_games/test_sensor.py +++ b/tests/components/nyt_games/test_sensor.py @@ -4,17 +4,23 @@ from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from nyt_games import NYTGamesError +from nyt_games import NYTGamesError, WordleStats import pytest from syrupy import SnapshotAssertion +from homeassistant.components.nyt_games.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_fixture, + snapshot_platform, +) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -55,3 +61,17 @@ async def test_updating_exception( await hass.async_block_till_done() assert hass.states.get("sensor.wordle_played").state != STATE_UNAVAILABLE + + +async def test_new_account( + hass: HomeAssistant, + mock_nyt_games_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling an exception during update.""" + mock_nyt_games_client.get_latest_stats.return_value = WordleStats.from_json( + load_fixture("new_account.json", DOMAIN) + ).player.stats + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.spelling_bee_played") is None From bb7803b02001a3966b2793a0944c3bb963bf055d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Sep 2024 20:23:24 +0200 Subject: [PATCH 015/134] Fix last played icon in NYT Games (#126837) --- homeassistant/components/nyt_games/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nyt_games/icons.json b/homeassistant/components/nyt_games/icons.json index 1f7b737a51b..2b839c1d218 100644 --- a/homeassistant/components/nyt_games/icons.json +++ b/homeassistant/components/nyt_games/icons.json @@ -26,7 +26,7 @@ "default": "mdi:table-large" }, "last_played": { - "default": "mdi:beehive-outline" + "default": "mdi:calendar" } } } From 42a4a89793e937ce40b1bba7b305581a1dae26a6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Sep 2024 20:19:40 +0200 Subject: [PATCH 016/134] Fix Withings reauth title (#126838) --- homeassistant/components/withings/config_flow.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 5eb4e08595a..150c0d52890 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -10,7 +10,7 @@ from aiowithings import AuthScope from homeassistant.components.webhook import async_generate_id from homeassistant.config_entries import ConfigEntry, ConfigFlowResult -from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID +from homeassistant.const import CONF_NAME, CONF_TOKEN, CONF_WEBHOOK_ID from homeassistant.helpers import config_entry_oauth2_flow from .const import DEFAULT_TITLE, DOMAIN @@ -52,7 +52,11 @@ class WithingsFlowHandler( ) -> ConfigFlowResult: """Confirm reauth dialog.""" if user_input is None: - return self.async_show_form(step_id="reauth_confirm") + assert self.reauth_entry + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self.reauth_entry.title}, + ) return await self.async_step_user() async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: From 73e56e292aba64bcece6393408d36984837bb244 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 26 Sep 2024 12:34:30 -0400 Subject: [PATCH 017/134] Bump aiohasupervisor to 0.1.0 (#126841) --- homeassistant/components/hassio/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index fe38fa78003..14e3f3598f1 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.1.0b1"] + "requirements": ["aiohasupervisor==0.1.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fbee44ed73c..186a591ca01 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 -aiohasupervisor==0.1.0b1 +aiohasupervisor==0.1.0 aiohttp-fast-zlib==0.1.1 aiohttp==3.10.6 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index b55956b5555..4c06f3af335 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "aiodns==3.2.0", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor - "aiohasupervisor==0.1.0b1", + "aiohasupervisor==0.1.0", "aiohttp==3.10.6", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", diff --git a/requirements.txt b/requirements.txt index ec1c0438a40..2e0f25eaabf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.1.0b1 +aiohasupervisor==0.1.0 aiohttp==3.10.6 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 diff --git a/requirements_all.txt b/requirements_all.txt index 0ed8a3da84e..200f5b6c874 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -258,7 +258,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.hassio -aiohasupervisor==0.1.0b1 +aiohasupervisor==0.1.0 # homeassistant.components.homekit_controller aiohomekit==3.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d08378b37cb..d126d21431e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -243,7 +243,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.hassio -aiohasupervisor==0.1.0b1 +aiohasupervisor==0.1.0 # homeassistant.components.homekit_controller aiohomekit==3.2.3 From 9a7254e4ee0f1153d01bac07ca9206c6ccfaaa68 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 26 Sep 2024 19:48:27 +0200 Subject: [PATCH 018/134] Update frontend to 20240926.0 (#126843) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 0ec8d4f3aa1..9c41488f10a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240925.0"] + "requirements": ["home-assistant-frontend==20240926.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 186a591ca01..712707a4702 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20240925.0 +home-assistant-frontend==20240926.0 home-assistant-intents==2024.9.23 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 200f5b6c874..a3e8c915cfa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1117,7 +1117,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20240925.0 +home-assistant-frontend==20240926.0 # homeassistant.components.conversation home-assistant-intents==2024.9.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d126d21431e..551cc018fa6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -943,7 +943,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20240925.0 +home-assistant-frontend==20240926.0 # homeassistant.components.conversation home-assistant-intents==2024.9.23 From b60e6082f77dbfbdcc8bda355b280373f6b48771 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Sep 2024 14:38:51 -0400 Subject: [PATCH 019/134] Update the Selected Pipeline entity name (#126845) --- homeassistant/components/assist_pipeline/strings.json | 2 +- tests/components/esphome/test_select.py | 2 +- tests/components/voip/test_select.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json index 956c17dad60..804d43c3a0a 100644 --- a/homeassistant/components/assist_pipeline/strings.json +++ b/homeassistant/components/assist_pipeline/strings.json @@ -7,7 +7,7 @@ }, "select": { "pipeline": { - "name": "Assist pipeline", + "name": "Assistant", "state": { "preferred": "Preferred" } diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index a433b1b0ab0..fbe30afd042 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -19,7 +19,7 @@ async def test_pipeline_selector( ) -> None: """Test assist pipeline selector.""" - state = hass.states.get("select.test_assist_pipeline") + state = hass.states.get("select.test_assistant") assert state is not None assert state.state == "preferred" diff --git a/tests/components/voip/test_select.py b/tests/components/voip/test_select.py index a9741b44081..78bb8d6c6b4 100644 --- a/tests/components/voip/test_select.py +++ b/tests/components/voip/test_select.py @@ -15,7 +15,7 @@ async def test_pipeline_select( Functionality is tested in assist_pipeline/test_select.py. This test is only to ensure it is set up. """ - state = hass.states.get("select.192_168_1_210_assist_pipeline") + state = hass.states.get("select.192_168_1_210_assistant") assert state is not None assert state.state == "preferred" From bdc548b4645a6cafc1fe096893a21ce61a514419 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Sep 2024 20:46:24 +0200 Subject: [PATCH 020/134] Bump version to 2024.10.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 0bd91a62c34..55d37ce9134 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 4c06f3af335..07b6a6543d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b0" +version = "2024.10.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From fdd8c0969bf98b3a746fdc77f4a0437247bd592b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Fri, 27 Sep 2024 10:30:40 +0300 Subject: [PATCH 021/134] Update overkiz Atlantic Water Heater away mode switching (#121801) --- homeassistant/components/overkiz/executor.py | 14 +++++-- ...stic_hot_water_production_mlb_component.py | 42 ++++++++++++++++--- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index 94b2c1b25fa..02829eaf1a3 100644 --- a/homeassistant/components/overkiz/executor.py +++ b/homeassistant/components/overkiz/executor.py @@ -81,8 +81,14 @@ class OverkizExecutor: return None - async def async_execute_command(self, command_name: str, *args: Any) -> None: - """Execute device command in async context.""" + async def async_execute_command( + self, command_name: str, *args: Any, refresh_afterwards: bool = True + ) -> None: + """Execute device command in async context. + + :param refresh_afterwards: Whether to refresh the device state after the command is executed. + If several commands are executed, it will be refreshed only once. + """ parameters = [arg for arg in args if arg is not None] # Set the execution duration to 0 seconds for RTS devices on supported commands # Default execution duration is 30 seconds and will block consecutive commands @@ -107,8 +113,8 @@ class OverkizExecutor: "device_url": self.device.device_url, "command_name": command_name, } - - await self.coordinator.async_refresh() + if refresh_afterwards: + await self.coordinator.async_refresh() async def async_cancel_command( self, commands_to_cancel: list[OverkizCommand] diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py index 0f57d13433b..1b2a1e218d4 100644 --- a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py +++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py @@ -97,9 +97,9 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE @property def is_away_mode_on(self) -> bool: """Return true if away mode is on.""" - return ( - self.executor.select_state(OverkizState.MODBUSLINK_DHW_ABSENCE_MODE) - == OverkizCommandParam.ON + return self.executor.select_state(OverkizState.MODBUSLINK_DHW_ABSENCE_MODE) in ( + OverkizCommandParam.ON, + OverkizCommandParam.PROG, ) @property @@ -151,10 +151,40 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE await self.async_turn_away_mode_on() async def async_turn_away_mode_on(self) -> None: - """Turn away mode on.""" - await self.executor.async_execute_command( - OverkizCommand.SET_ABSENCE_MODE, OverkizCommandParam.ON + """Turn away mode on. + + This requires the start date and the end date to be also set. + The API accepts setting dates in the format of the core:DateTimeState state for the DHW + {'day': 11, 'hour': 21, 'minute': 12, 'month': 7, 'second': 53, 'weekday': 3, 'year': 2024}) + The dict is then passed as an away mode start date, and then as an end date, but with the year incremented by 1, + so the away mode is getting turned on for the next year. + The weekday number seems to have no effect so the calculation of the future date's weekday number is redundant, + but possible via homeassistant dt_util to form both start and end dates dictionaries from scratch + based on datetime.now() and datetime.timedelta into the future. + If you execute `setAbsenceStartDate`, `setAbsenceEndDate` and `setAbsenceMode`, + the API answers with "too many requests", as there's a polling update after each command execution, + and the device becomes unavailable until the API is available again. + With `refresh_afterwards=False` on the first commands, and `refresh_afterwards=True` only the last command, + the API is not choking and the transition is smooth without the unavailability state. + """ + now_date = cast( + dict, + self.executor.select_state(OverkizState.CORE_DATETIME), ) + await self.executor.async_execute_command( + OverkizCommand.SET_ABSENCE_MODE, + OverkizCommandParam.PROG, + refresh_afterwards=False, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_ABSENCE_START_DATE, now_date, refresh_afterwards=False + ) + now_date["year"] = now_date["year"] + 1 + await self.executor.async_execute_command( + OverkizCommand.SET_ABSENCE_END_DATE, now_date, refresh_afterwards=False + ) + + await self.coordinator.async_refresh() async def async_turn_away_mode_off(self) -> None: """Turn away mode off.""" From ebfd442b517c3e2e5140c254c4571e2197b884f4 Mon Sep 17 00:00:00 2001 From: Kareem ElFaramawi Date: Fri, 27 Sep 2024 05:43:29 -0400 Subject: [PATCH 022/134] Fix Abode integration needing to reauthenticate after core update (#123035) * bump jaraco.abode to 6.2.1 * update abode user_data path to HA config * Move abode config call out of try block --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/abode/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index a27eda2cf12..0542e362268 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -4,8 +4,10 @@ from __future__ import annotations from dataclasses import dataclass, field from functools import partial +from pathlib import Path from jaraco.abode.client import Client as Abode +import jaraco.abode.config from jaraco.abode.exceptions import ( AuthenticationException as AbodeAuthenticationException, Exception as AbodeException, @@ -93,6 +95,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: password = entry.data[CONF_PASSWORD] polling = entry.data[CONF_POLLING] + # Configure abode library to use config directory for storing data + jaraco.abode.config.paths.override(user_data=Path(hass.config.path("Abode"))) + # For previous config entries where unique_id is None if entry.unique_id is None: hass.config_entries.async_update_entry( From bb73529770ee20ecca0b812c2bb34d9c8579b5d8 Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 27 Sep 2024 01:18:37 -0600 Subject: [PATCH 023/134] Monarch Money cashflow sensor bugfix (#125774) Co-authored-by: Franck Nijhof --- homeassistant/components/monarch_money/coordinator.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/monarch_money/coordinator.py b/homeassistant/components/monarch_money/coordinator.py index 8eb15d448ec..3e689c48e91 100644 --- a/homeassistant/components/monarch_money/coordinator.py +++ b/homeassistant/components/monarch_money/coordinator.py @@ -2,7 +2,7 @@ import asyncio from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from aiohttp import ClientResponseError from gql.transport.exceptions import TransportServerError @@ -63,9 +63,13 @@ class MonarchMoneyDataUpdateCoordinator(DataUpdateCoordinator[MonarchData]): async def _async_update_data(self) -> MonarchData: """Fetch data for all accounts.""" + now = datetime.now() + account_data, cashflow_summary = await asyncio.gather( self.client.get_accounts_as_dict_with_id_key(), - self.client.get_cashflow_summary(), + self.client.get_cashflow_summary( + start_date=f"{now.year}-01-01", end_date=f"{now.year}-12-31" + ), ) return MonarchData(account_data=account_data, cashflow_summary=cashflow_summary) From 28d491e997e4e5d95176f20c22a6ad9f25703057 Mon Sep 17 00:00:00 2001 From: EnjoyingM <6302356+mtielen@users.noreply.github.com> Date: Fri, 27 Sep 2024 09:56:33 +0200 Subject: [PATCH 024/134] Bump wolf-comm to 0.0.15 (#126857) --- homeassistant/components/wolflink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index daa7d187bfb..4bfc0e6dd83 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wolflink", "iot_class": "cloud_polling", "loggers": ["wolf_comm"], - "requirements": ["wolf-comm==0.0.10"] + "requirements": ["wolf-comm==0.0.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index a3e8c915cfa..38145ce6e2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2977,7 +2977,7 @@ wirelesstagpy==0.8.1 wled==0.20.2 # homeassistant.components.wolflink -wolf-comm==0.0.10 +wolf-comm==0.0.15 # homeassistant.components.wyoming wyoming==1.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 551cc018fa6..829c5ed6a6c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2366,7 +2366,7 @@ wiffi==1.1.2 wled==0.20.2 # homeassistant.components.wolflink -wolf-comm==0.0.10 +wolf-comm==0.0.15 # homeassistant.components.wyoming wyoming==1.5.4 From 60641d5a4e95b2ed1ac0d43381266c0f2550e55a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 27 Sep 2024 11:37:28 +0200 Subject: [PATCH 025/134] Fix restoring state class in mobile app (#126868) --- homeassistant/components/mobile_app/sensor.py | 2 + tests/components/mobile_app/test_sensor.py | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index dd70cf1e22e..9e3431e0e90 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -59,6 +59,8 @@ async def async_setup_entry( ATTR_SENSOR_UOM: entry.unit_of_measurement, ATTR_SENSOR_ENTITY_CATEGORY: entry.entity_category, } + if capabilities := entry.capabilities: + config[ATTR_SENSOR_STATE_CLASS] = capabilities.get(ATTR_SENSOR_STATE_CLASS) entities.append(MobileAppSensor(config, config_entry)) async_add_entities(entities) diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index 6411274fc4e..fb124797523 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -622,3 +622,78 @@ async def test_updating_disabled_sensor( json = await update_resp.json() assert json["battery_state"]["success"] is True assert json["battery_state"]["is_disabled"] is True + + +async def test_recreate_correct_from_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that sensors can be re-created from entity registry.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "device_class": "battery", + "icon": "mdi:battery", + "name": "Battery State", + "state": 100, + "type": "sensor", + "unique_id": "battery_state", + "unit_of_measurement": PERCENTAGE, + "state_class": "measurement", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + + update_resp = await webhook_client.post( + webhook_url, + json={ + "type": "update_sensor_states", + "data": [ + { + "icon": "mdi:battery-unknown", + "state": 123, + "type": "sensor", + "unique_id": "battery_state", + }, + ], + }, + ) + + assert update_resp.status == HTTPStatus.OK + + entity = hass.states.get("sensor.test_1_battery_state") + + assert entity is not None + entity_entry = entity_registry.async_get("sensor.test_1_battery_state") + assert entity_entry is not None + + assert entity_entry.capabilities == { + "state_class": "measurement", + } + + entry = hass.config_entries.async_entries("mobile_app")[1] + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test_1_battery_state").state == STATE_UNAVAILABLE + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get("sensor.test_1_battery_state") + assert entity_entry is not None + assert hass.states.get("sensor.test_1_battery_state") is not None + + assert entity_entry.capabilities == { + "state_class": "measurement", + } From 3d1bd626b090ee943e3fb21a56332774dbb87cf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Sep 2024 04:35:57 -0500 Subject: [PATCH 026/134] Bump yarl to 1.13.0 (#126872) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 712707a4702..171a4db310f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -63,7 +63,7 @@ uv==0.4.15 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.12.1 +yarl==1.13.0 zeroconf==0.135.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 07b6a6543d2..921cb30d54e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.12.1", + "yarl==1.13.0", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 2e0f25eaabf..6fc605fd5ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,4 +42,4 @@ uv==0.4.15 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.12.1 +yarl==1.13.0 From b079a94bef68ad08d02376605d77a9e8600dc7e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Sep 2024 03:36:05 -0500 Subject: [PATCH 027/134] Fix getting the host for the current request (#126882) --- homeassistant/helpers/network.py | 6 +++++- tests/helpers/test_network.py | 36 +++++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 36c9feb83c4..cd3f4c65570 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -6,6 +6,7 @@ from collections.abc import Callable from contextlib import suppress from ipaddress import ip_address +from aiohttp import hdrs from hass_nabucasa import remote import yarl @@ -216,7 +217,10 @@ def _get_request_host() -> str | None: """Get the host address of the current request.""" if (request := http.current_request.get()) is None: raise NoURLAvailableError - return request.url.host + # partition the host to remove the port + # because the raw host header can contain the port + host = request.headers.get(hdrs.HOST) + return None if host is None else host.partition(":")[0] @bind_hass diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 5a847e6a29c..5b8b6652369 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -2,6 +2,8 @@ from unittest.mock import Mock, patch +from aiohttp import hdrs +from multidict import CIMultiDict, CIMultiDictProxy import pytest from yarl import URL @@ -592,7 +594,11 @@ async def test_get_request_host(hass: HomeAssistant) -> None: with patch("homeassistant.components.http.current_request") as mock_request_context: mock_request = Mock() + mock_request.headers = CIMultiDictProxy( + CIMultiDict({hdrs.HOST: "example.com:8123"}) + ) mock_request.url = URL("http://example.com:8123/test/request") + mock_request.host = "example.com:8123" mock_request_context.get = Mock(return_value=mock_request) assert _get_request_host() == "example.com" @@ -683,11 +689,19 @@ async def test_is_internal_request(hass: HomeAssistant, mock_current_request) -> mock_current_request.return_value = None assert not is_internal_request(hass) - mock_current_request.return_value = Mock(url=URL("http://example.local:8123")) + mock_current_request.return_value = Mock( + headers=CIMultiDictProxy(CIMultiDict({hdrs.HOST: "example.local:8123"})), + host="example.local:8123", + url=URL("http://example.local:8123"), + ) assert is_internal_request(hass) mock_current_request.return_value = Mock( - url=URL("http://no_match.example.local:8123") + headers=CIMultiDictProxy( + CIMultiDict({hdrs.HOST: "no_match.example.local:8123"}) + ), + host="no_match.example.local:8123", + url=URL("http://no_match.example.local:8123"), ) assert not is_internal_request(hass) @@ -700,18 +714,30 @@ async def test_is_internal_request(hass: HomeAssistant, mock_current_request) -> assert hass.config.internal_url == "http://192.168.0.1:8123" assert not is_internal_request(hass) - mock_current_request.return_value = Mock(url=URL("http://192.168.0.1:8123")) + mock_current_request.return_value = Mock( + headers=CIMultiDictProxy(CIMultiDict({hdrs.HOST: "192.168.0.1:8123"})), + host="192.168.0.1:8123", + url=URL("http://192.168.0.1:8123"), + ) assert is_internal_request(hass) # Test for matching against local IP hass.config.api = Mock(use_ssl=False, local_ip="192.168.123.123", port=8123) for allowed in ("127.0.0.1", "192.168.123.123"): - mock_current_request.return_value = Mock(url=URL(f"http://{allowed}:8123")) + mock_current_request.return_value = Mock( + headers=CIMultiDictProxy(CIMultiDict({hdrs.HOST: f"{allowed}:8123"})), + host=f"{allowed}:8123", + url=URL(f"http://{allowed}:8123"), + ) assert is_internal_request(hass), mock_current_request.return_value.url # Test for matching against HassOS hostname for allowed in ("hellohost", "hellohost.local"): - mock_current_request.return_value = Mock(url=URL(f"http://{allowed}:8123")) + mock_current_request.return_value = Mock( + headers=CIMultiDictProxy(CIMultiDict({hdrs.HOST: f"{allowed}:8123"})), + host=f"{allowed}:8123", + url=URL(f"http://{allowed}:8123"), + ) assert is_internal_request(hass), mock_current_request.return_value.url From 2749b1f0576d6fbd016fd2752e4a5f5548227c03 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Sep 2024 10:26:19 +0200 Subject: [PATCH 028/134] Mark custom panel integration as system type (#126883) --- homeassistant/components/panel_custom/manifest.json | 1 + homeassistant/generated/integrations.json | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/panel_custom/manifest.json b/homeassistant/components/panel_custom/manifest.json index ab5c4931b57..1b4bef6bc99 100644 --- a/homeassistant/components/panel_custom/manifest.json +++ b/homeassistant/components/panel_custom/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@home-assistant/frontend"], "dependencies": ["frontend"], "documentation": "https://www.home-assistant.io/integrations/panel_custom", + "integration_type": "system", "quality_scale": "internal" } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 423f239ce2d..d43a2aec5a2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4537,11 +4537,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "panel_custom": { - "name": "Custom Panel", - "integration_type": "hub", - "config_flow": false - }, "panel_iframe": { "name": "iframe Panel", "integration_type": "hub", From ec66c7e53495261016dce06ffcdbd62eb3185aab Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 27 Sep 2024 11:35:35 +0200 Subject: [PATCH 029/134] Add diagnostics platform to airgradient (#126886) --- .../components/airgradient/diagnostics.py | 18 ++++++++ .../snapshots/test_diagnostics.ambr | 42 +++++++++++++++++++ .../airgradient/test_diagnostics.py | 29 +++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 homeassistant/components/airgradient/diagnostics.py create mode 100644 tests/components/airgradient/snapshots/test_diagnostics.ambr create mode 100644 tests/components/airgradient/test_diagnostics.py diff --git a/homeassistant/components/airgradient/diagnostics.py b/homeassistant/components/airgradient/diagnostics.py new file mode 100644 index 00000000000..dfc3262193a --- /dev/null +++ b/homeassistant/components/airgradient/diagnostics.py @@ -0,0 +1,18 @@ +"""Diagnostics support for Airgradient.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import AirGradientConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: AirGradientConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return asdict(entry.runtime_data.data) diff --git a/tests/components/airgradient/snapshots/test_diagnostics.ambr b/tests/components/airgradient/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a96dfb95382 --- /dev/null +++ b/tests/components/airgradient/snapshots/test_diagnostics.ambr @@ -0,0 +1,42 @@ +# serializer version: 1 +# name: test_diagnostics_polling_instance + dict({ + 'config': dict({ + 'co2_automatic_baseline_calibration_days': 8, + 'configuration_control': 'local', + 'country': 'DE', + 'display_brightness': 0, + 'led_bar_brightness': 100, + 'led_bar_mode': 'co2', + 'nox_learning_offset': 12, + 'pm_standard': 'ugm3', + 'post_data_to_airgradient': True, + 'temperature_unit': 'c', + 'tvoc_learning_offset': 12, + }), + 'measures': dict({ + 'ambient_temperature': 22.17, + 'boot_time': 28, + 'compensated_ambient_temperature': 22.17, + 'compensated_pm02': None, + 'compensated_relative_humidity': 47.0, + 'firmware_version': '3.1.1', + 'model': 'I-9PSL', + 'nitrogen_index': 1, + 'pm003_count': 270, + 'pm01': 22, + 'pm02': 34, + 'pm10': 41, + 'raw_ambient_temperature': 27.96, + 'raw_nitrogen': 16931, + 'raw_pm02': 34, + 'raw_relative_humidity': 48.0, + 'raw_total_volatile_organic_component': 31792, + 'rco2': 778, + 'relative_humidity': 47.0, + 'serial_number': '84fce612f5b8', + 'signal_strength': -52, + 'total_volatile_organic_component_index': 99, + }), + }) +# --- diff --git a/tests/components/airgradient/test_diagnostics.py b/tests/components/airgradient/test_diagnostics.py new file mode 100644 index 00000000000..34a9bb7aab2 --- /dev/null +++ b/tests/components/airgradient/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the AirGradient integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics_polling_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From e66dd63516104c8e01050547795e1f620ba29314 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Sep 2024 04:26:35 -0500 Subject: [PATCH 030/134] Fix getting the current host for IPv6 urls (#126889) --- homeassistant/helpers/network.py | 10 +++++- tests/helpers/test_network.py | 61 +++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index cd3f4c65570..fa7fec9faea 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -220,7 +220,15 @@ def _get_request_host() -> str | None: # partition the host to remove the port # because the raw host header can contain the port host = request.headers.get(hdrs.HOST) - return None if host is None else host.partition(":")[0] + if host is None: + return None + # IPv6 addresses are enclosed in brackets + # use same logic as yarl and urllib to extract the host + if "[" in host: + return (host.partition("[")[2]).partition("]")[0] + if ":" in host: + host = host.partition(":")[0] + return host @bind_hass diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 5b8b6652369..0787c56219f 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -587,7 +587,7 @@ async def test_get_url(hass: HomeAssistant) -> None: assert get_url(hass, allow_internal=False) -async def test_get_request_host(hass: HomeAssistant) -> None: +async def test_get_request_host_with_port(hass: HomeAssistant) -> None: """Test getting the host of the current web request from the request context.""" with pytest.raises(NoURLAvailableError): _get_request_host() @@ -604,6 +604,65 @@ async def test_get_request_host(hass: HomeAssistant) -> None: assert _get_request_host() == "example.com" +async def test_get_request_host_without_port(hass: HomeAssistant) -> None: + """Test getting the host of the current web request from the request context.""" + with pytest.raises(NoURLAvailableError): + _get_request_host() + + with patch("homeassistant.components.http.current_request") as mock_request_context: + mock_request = Mock() + mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "example.com"})) + mock_request.url = URL("http://example.com/test/request") + mock_request.host = "example.com" + mock_request_context.get = Mock(return_value=mock_request) + + assert _get_request_host() == "example.com" + + +async def test_get_request_ipv6_address(hass: HomeAssistant) -> None: + """Test getting the ipv6 host of the current web request from the request context.""" + with pytest.raises(NoURLAvailableError): + _get_request_host() + + with patch("homeassistant.components.http.current_request") as mock_request_context: + mock_request = Mock() + mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "[::1]:8123"})) + mock_request.url = URL("http://[::1]:8123/test/request") + mock_request.host = "[::1]:8123" + mock_request_context.get = Mock(return_value=mock_request) + + assert _get_request_host() == "::1" + + +async def test_get_request_ipv6_address_without_port(hass: HomeAssistant) -> None: + """Test getting the ipv6 host of the current web request from the request context.""" + with pytest.raises(NoURLAvailableError): + _get_request_host() + + with patch("homeassistant.components.http.current_request") as mock_request_context: + mock_request = Mock() + mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "[::1]"})) + mock_request.url = URL("http://[::1]/test/request") + mock_request.host = "[::1]" + mock_request_context.get = Mock(return_value=mock_request) + + assert _get_request_host() == "::1" + + +async def test_get_request_host_no_host_header(hass: HomeAssistant) -> None: + """Test getting the host of the current web request from the request context.""" + with pytest.raises(NoURLAvailableError): + _get_request_host() + + with patch("homeassistant.components.http.current_request") as mock_request_context: + mock_request = Mock() + mock_request.headers = CIMultiDictProxy(CIMultiDict()) + mock_request.url = URL("/test/request") + mock_request_context.get = Mock(return_value=mock_request) + + assert _get_request_host() is None + + @patch("homeassistant.components.hassio.is_hassio", Mock(return_value=True)) @patch( "homeassistant.components.hassio.get_host_info", From a3e3edb9a29128bd3dc4f9c8999921ceb08671a5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Sep 2024 11:53:10 +0200 Subject: [PATCH 031/134] Bump version to 2024.10.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 55d37ce9134..dda328b0873 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 921cb30d54e..bd5f5f4a09c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b1" +version = "2024.10.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From bae6d679aa41bbfee77888fb93a27285c38f3db3 Mon Sep 17 00:00:00 2001 From: Simon <80467011+sorgfresser@users.noreply.github.com> Date: Fri, 27 Sep 2024 18:04:31 +0100 Subject: [PATCH 032/134] Use hass httpx client for ElevenLabs component (#126793) --- homeassistant/components/elevenlabs/__init__.py | 6 +++++- homeassistant/components/elevenlabs/config_flow.py | 13 +++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py index 99cddd783e2..7da4802e98a 100644 --- a/homeassistant/components/elevenlabs/__init__.py +++ b/homeassistant/components/elevenlabs/__init__.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.httpx_client import get_async_client from .const import CONF_MODEL @@ -41,7 +42,10 @@ type EleventLabsConfigEntry = ConfigEntry[ElevenLabsData] async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry) -> bool: """Set up ElevenLabs text-to-speech from a config entry.""" entry.add_update_listener(update_listener) - client = AsyncElevenLabs(api_key=entry.data[CONF_API_KEY]) + httpx_client = get_async_client(hass) + client = AsyncElevenLabs( + api_key=entry.data[CONF_API_KEY], httpx_client=httpx_client + ) model_id = entry.options[CONF_MODEL] try: model = await get_model_by_id(client, model_id) diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index 6eec35d0583..b596ec05b00 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -17,6 +17,8 @@ from homeassistant.config_entries import ( OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -47,9 +49,12 @@ USER_STEP_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) _LOGGER = logging.getLogger(__name__) -async def get_voices_models(api_key: str) -> tuple[dict[str, str], dict[str, str]]: +async def get_voices_models( + hass: HomeAssistant, api_key: str +) -> tuple[dict[str, str], dict[str, str]]: """Get available voices and models as dicts.""" - client = AsyncElevenLabs(api_key=api_key) + httpx_client = get_async_client(hass) + client = AsyncElevenLabs(api_key=api_key, httpx_client=httpx_client) voices = (await client.voices.get_all()).voices models = await client.models.get_all() voices_dict = { @@ -77,7 +82,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: try: - voices, _ = await get_voices_models(user_input[CONF_API_KEY]) + voices, _ = await get_voices_models(self.hass, user_input[CONF_API_KEY]) except ApiError: errors["base"] = "invalid_api_key" else: @@ -116,7 +121,7 @@ class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry): ) -> ConfigFlowResult: """Manage the options.""" if not self.voices or not self.models: - self.voices, self.models = await get_voices_models(self.api_key) + self.voices, self.models = await get_voices_models(self.hass, self.api_key) assert self.models and self.voices From e8636670d4174aee8751e5a60be73864aef21e77 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Fri, 27 Sep 2024 12:21:58 +0200 Subject: [PATCH 033/134] Bump python-linkplay to 0.0.12 (#126850) Bump dependency --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index 66a719c640e..8adae25b0ae 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/linkplay", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["python-linkplay==0.0.9"], + "requirements": ["python-linkplay==0.0.12"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 38145ce6e2b..90fccb44004 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2343,7 +2343,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.3 # homeassistant.components.linkplay -python-linkplay==0.0.9 +python-linkplay==0.0.12 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 829c5ed6a6c..57edf801448 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1864,7 +1864,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.3 # homeassistant.components.linkplay -python-linkplay==0.0.9 +python-linkplay==0.0.12 # homeassistant.components.matter python-matter-server==6.5.2 From a4ff292231ddbdb55282d05f43b549344fe7a631 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Sep 2024 18:32:20 +0200 Subject: [PATCH 034/134] Improve statistics issue title (#126851) --- homeassistant/components/sensor/recorder.py | 9 +++------ homeassistant/components/sensor/strings.json | 8 ++++---- tests/components/sensor/test_recorder.py | 16 ++++++---------- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index f81c3308943..be0feb7fa52 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -693,15 +693,12 @@ def _update_issues( if state_class is None: # Sensor no longer has a valid state class report_issue( - "unsupported_state_class", + "state_class_removed", entity_id, - { - "statistic_id": entity_id, - "state_class": state_class, - }, + {"statistic_id": entity_id}, ) else: - clear_issue("unsupported_state_class", entity_id) + clear_issue("state_class_removed", entity_id) metadata_unit = metadata[1]["unit_of_measurement"] converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER.get(metadata_unit) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 4ef7dbc74f0..71bead342c4 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -289,12 +289,12 @@ } }, "issues": { - "units_changed": { - "title": "The unit of {statistic_id} has changed", + "state_class_removed": { + "title": "{statistic_id} no longer has a state class", "description": "" }, - "unsupported_state_class": { - "title": "The state class of {statistic_id} is not supported", + "units_changed": { + "title": "The unit of {statistic_id} has changed", "description": "" } } diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 821c10e02d9..77bb6e17f68 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4580,7 +4580,7 @@ async def test_validate_statistics_unit_change_no_device_class( (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), ], ) -async def test_validate_statistics_unsupported_state_class( +async def test_validate_statistics_state_class_removed( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, units, @@ -4620,15 +4620,12 @@ async def test_validate_statistics_unsupported_state_class( expected = { "sensor.test": [ { - "data": { - "state_class": None, - "statistic_id": "sensor.test", - }, - "type": "unsupported_state_class", + "data": {"statistic_id": "sensor.test"}, + "type": "state_class_removed", } ], } - await assert_validation_result(hass, client, expected, {"unsupported_state_class"}) + await assert_validation_result(hass, client, expected, {"state_class_removed"}) @pytest.mark.parametrize( @@ -5130,9 +5127,8 @@ async def test_update_statistics_issues( # Let statistics run for one hour, expect issue now = await one_hour_stats(now) expected = { - "unsupported_state_class_sensor.test": { - "issue_type": "unsupported_state_class", - "state_class": None, + "state_class_removed_sensor.test": { + "issue_type": "state_class_removed", "statistic_id": "sensor.test", } } From 0f3f50e8171abe4cee11380323229901a7425321 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 27 Sep 2024 12:00:19 +0200 Subject: [PATCH 035/134] Add support for variant of Xiaomi Mi Air Purifier 3C (zhimi.airp.mb4a) (#126867) Add model id zhimi.airp.mb4a --- homeassistant/components/xiaomi_miio/const.py | 2 ++ homeassistant/components/xiaomi_miio/fan.py | 3 ++- homeassistant/components/xiaomi_miio/number.py | 2 ++ homeassistant/components/xiaomi_miio/sensor.py | 2 ++ homeassistant/components/xiaomi_miio/switch.py | 2 ++ 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index a8b1f8d4ba5..852157f87db 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -60,6 +60,7 @@ MODEL_AIRPURIFIER_2H = "zhimi.airpurifier.mc2" MODEL_AIRPURIFIER_2S = "zhimi.airpurifier.mc1" MODEL_AIRPURIFIER_3 = "zhimi.airpurifier.ma4" MODEL_AIRPURIFIER_3C = "zhimi.airpurifier.mb4" +MODEL_AIRPURIFIER_3C_REV_A = "zhimi.airp.mb4a" MODEL_AIRPURIFIER_3H = "zhimi.airpurifier.mb3" MODEL_AIRPURIFIER_COMPACT = "xiaomi.airp.cpa4" MODEL_AIRPURIFIER_M1 = "zhimi.airpurifier.m1" @@ -126,6 +127,7 @@ MODELS_FAN_MIOT = [ MODELS_PURIFIER_MIOT = [ MODEL_AIRPURIFIER_3, MODEL_AIRPURIFIER_3C, + MODEL_AIRPURIFIER_3C_REV_A, MODEL_AIRPURIFIER_3H, MODEL_AIRPURIFIER_PROH, MODEL_AIRPURIFIER_PROH_EU, diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 88752c35698..b8f92bd89b0 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -71,6 +71,7 @@ from .const import ( MODEL_AIRPURIFIER_2H, MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_3C, + MODEL_AIRPURIFIER_3C_REV_A, MODEL_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_LITE_RMA1, MODEL_AIRPURIFIER_4_LITE_RMB1, @@ -215,7 +216,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - if model == MODEL_AIRPURIFIER_3C: + if model in (MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_3C_REV_A): entity = XiaomiAirPurifierMB4( device, config_entry, diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index e284027d4c1..f8788ba07d6 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -72,6 +72,7 @@ from .const import ( MODEL_AIRHUMIDIFIER_CB1, MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_3C, + MODEL_AIRPURIFIER_3C_REV_A, MODEL_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_LITE_RMA1, MODEL_AIRPURIFIER_4_LITE_RMB1, @@ -244,6 +245,7 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRHUMIDIFIER_CB1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, MODEL_AIRPURIFIER_2S: FEATURE_FLAGS_AIRPURIFIER_2S, MODEL_AIRPURIFIER_3C: FEATURE_FLAGS_AIRPURIFIER_3C, + MODEL_AIRPURIFIER_3C_REV_A: FEATURE_FLAGS_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO: FEATURE_FLAGS_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index d34972b3793..3f6f4e9b50b 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -62,6 +62,7 @@ from .const import ( MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1, MODEL_AIRPURIFIER_3C, + MODEL_AIRPURIFIER_3C_REV_A, MODEL_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_LITE_RMA1, MODEL_AIRPURIFIER_4_LITE_RMB1, @@ -560,6 +561,7 @@ MODEL_TO_SENSORS_MAP: dict[str, tuple[str, ...]] = { MODEL_AIRHUMIDIFIER_CA1: HUMIDIFIER_CA1_CB1_SENSORS, MODEL_AIRHUMIDIFIER_CB1: HUMIDIFIER_CA1_CB1_SENSORS, MODEL_AIRPURIFIER_3C: PURIFIER_3C_SENSORS, + MODEL_AIRPURIFIER_3C_REV_A: PURIFIER_3C_SENSORS, MODEL_AIRPURIFIER_4_LITE_RMA1: PURIFIER_4_LITE_SENSORS, MODEL_AIRPURIFIER_4_LITE_RMB1: PURIFIER_4_LITE_SENSORS, MODEL_AIRPURIFIER_4: PURIFIER_4_SENSORS, diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 57a1a155c38..8df3522b2ac 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -84,6 +84,7 @@ from .const import ( MODEL_AIRPURIFIER_2H, MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_3C, + MODEL_AIRPURIFIER_3C_REV_A, MODEL_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_LITE_RMA1, MODEL_AIRPURIFIER_4_LITE_RMB1, @@ -199,6 +200,7 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRPURIFIER_2H: FEATURE_FLAGS_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2S: FEATURE_FLAGS_AIRPURIFIER_2S, MODEL_AIRPURIFIER_3C: FEATURE_FLAGS_AIRPURIFIER_3C, + MODEL_AIRPURIFIER_3C_REV_A: FEATURE_FLAGS_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO: FEATURE_FLAGS_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, From a45c4ec8e92285d0fc5835f8329e777b2291e288 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 27 Sep 2024 13:11:28 +0200 Subject: [PATCH 036/134] Fix blocking call in Xiaomi Miio integration (#126871) --- homeassistant/components/xiaomi_miio/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index c689ede27eb..bd925b5fc54 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -237,7 +237,9 @@ class XiaomiMiioFlowHandler(ConfigFlow, domain=DOMAIN): step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors ) - miio_cloud = MiCloud(cloud_username, cloud_password) + miio_cloud = await self.hass.async_add_executor_job( + MiCloud, cloud_username, cloud_password + ) try: if not await self.hass.async_add_executor_job(miio_cloud.login): errors["base"] = "cloud_login_error" From 8d1f9440967c75a860f267c7fb2d8829566b57c0 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 27 Sep 2024 14:41:20 +0200 Subject: [PATCH 037/134] Revert "Add support for Xiaomi airpurifier and humidifier (#117791)" (#126873) --- homeassistant/components/xiaomi_miio/const.py | 4 ---- homeassistant/components/xiaomi_miio/select.py | 4 ---- 2 files changed, 8 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 852157f87db..7d6cf152d7a 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -62,7 +62,6 @@ MODEL_AIRPURIFIER_3 = "zhimi.airpurifier.ma4" MODEL_AIRPURIFIER_3C = "zhimi.airpurifier.mb4" MODEL_AIRPURIFIER_3C_REV_A = "zhimi.airp.mb4a" MODEL_AIRPURIFIER_3H = "zhimi.airpurifier.mb3" -MODEL_AIRPURIFIER_COMPACT = "xiaomi.airp.cpa4" MODEL_AIRPURIFIER_M1 = "zhimi.airpurifier.m1" MODEL_AIRPURIFIER_M2 = "zhimi.airpurifier.m2" MODEL_AIRPURIFIER_MA1 = "zhimi.airpurifier.ma1" @@ -85,7 +84,6 @@ MODEL_AIRHUMIDIFIER_CA4 = "zhimi.humidifier.ca4" MODEL_AIRHUMIDIFIER_CB1 = "zhimi.humidifier.cb1" MODEL_AIRHUMIDIFIER_JSQ = "deerma.humidifier.jsq" MODEL_AIRHUMIDIFIER_JSQ1 = "deerma.humidifier.jsq1" -MODEL_AIRHUMIDIFIER_JSQ2W = "deerma.humidifier.jsq2w" MODEL_AIRHUMIDIFIER_MJJSQ = "deerma.humidifier.mjjsq" MODEL_AIRFRESH_A1 = "dmaker.airfresh.a1" @@ -152,7 +150,6 @@ MODELS_PURIFIER_MIIO = [ MODEL_AIRPURIFIER_SA2, MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2H, - MODEL_AIRPURIFIER_COMPACT, MODEL_AIRFRESH_A1, MODEL_AIRFRESH_VA2, MODEL_AIRFRESH_VA4, @@ -167,7 +164,6 @@ MODELS_HUMIDIFIER_MIOT = [MODEL_AIRHUMIDIFIER_CA4] MODELS_HUMIDIFIER_MJJSQ = [ MODEL_AIRHUMIDIFIER_JSQ, MODEL_AIRHUMIDIFIER_JSQ1, - MODEL_AIRHUMIDIFIER_JSQ2W, MODEL_AIRHUMIDIFIER_MJJSQ, ] diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 55c9105b177..eb0d6bca205 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -50,7 +50,6 @@ from .const import ( MODEL_AIRPURIFIER_3H, MODEL_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_PRO, - MODEL_AIRPURIFIER_COMPACT, MODEL_AIRPURIFIER_M1, MODEL_AIRPURIFIER_M2, MODEL_AIRPURIFIER_MA2, @@ -130,9 +129,6 @@ MODEL_TO_ATTR_MAP: dict[str, list] = { MODEL_AIRPURIFIER_4_PRO: [ AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) ], - MODEL_AIRPURIFIER_COMPACT: [ - AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) - ], MODEL_AIRPURIFIER_M1: [ AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierLedBrightness) ], From 840cc483b01d1212b918df31c6702ac4820a08f7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 27 Sep 2024 13:21:35 +0200 Subject: [PATCH 038/134] Update airgradient device sw_version when changed (#126902) --- .../components/airgradient/coordinator.py | 24 +++++++++++++-- tests/components/airgradient/test_init.py | 29 ++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py index 4e1c335019c..03d58645853 100644 --- a/homeassistant/components/airgradient/coordinator.py +++ b/homeassistant/components/airgradient/coordinator.py @@ -9,9 +9,10 @@ from typing import TYPE_CHECKING from airgradient import AirGradientClient, AirGradientError, Config, Measures from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import LOGGER +from .const import DOMAIN, LOGGER if TYPE_CHECKING: from . import AirGradientConfigEntry @@ -29,6 +30,7 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]): """Class to manage fetching AirGradient data.""" config_entry: AirGradientConfigEntry + _current_version: str def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None: """Initialize coordinator.""" @@ -42,11 +44,27 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]): assert self.config_entry.unique_id self.serial_number = self.config_entry.unique_id + async def _async_setup(self) -> None: + """Set up the coordinator.""" + self._current_version = ( + await self.client.get_current_measures() + ).firmware_version + async def _async_update_data(self) -> AirGradientData: try: measures = await self.client.get_current_measures() config = await self.client.get_config() except AirGradientError as error: raise UpdateFailed(error) from error - else: - return AirGradientData(measures, config) + if measures.firmware_version != self._current_version: + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, self.serial_number)} + ) + assert device_entry + device_registry.async_update_device( + device_entry.id, + sw_version=measures.firmware_version, + ) + self._current_version = measures.firmware_version + return AirGradientData(measures, config) diff --git a/tests/components/airgradient/test_init.py b/tests/components/airgradient/test_init.py index a566254d106..a121940f2bc 100644 --- a/tests/components/airgradient/test_init.py +++ b/tests/components/airgradient/test_init.py @@ -1,7 +1,9 @@ """Tests for the AirGradient integration.""" +from datetime import timedelta from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN @@ -10,7 +12,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_device_info( @@ -27,3 +29,28 @@ async def test_device_info( ) assert device_entry is not None assert device_entry == snapshot + + +async def test_new_firmware_version( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry.sw_version == "3.1.1" + mock_airgradient_client.get_current_measures.return_value.firmware_version = "3.1.2" + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry.sw_version == "3.1.2" From 57028a080763cf5424def3003defb1a997f95e6e Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Fri, 27 Sep 2024 17:43:25 +0200 Subject: [PATCH 039/134] Use icon translations in unifi (#126903) * Use icon translations in unifi * Update snapshots * Add state icons * Address feedback * Update snapshot --- homeassistant/components/unifi/icons.json | 48 +++++++++++++++ homeassistant/components/unifi/sensor.py | 8 +-- homeassistant/components/unifi/switch.py | 12 ++-- .../unifi/snapshots/test_sensor.ambr | 60 ++++++++----------- .../unifi/snapshots/test_switch.ambr | 40 +++++-------- 5 files changed, 98 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json index b089d8eff9c..525d089d6d4 100644 --- a/homeassistant/components/unifi/icons.json +++ b/homeassistant/components/unifi/icons.json @@ -1,4 +1,52 @@ { + "entity": { + "sensor": { + "client_bandwidth_rx": { + "default": "mdi:download" + }, + "client_bandwidth_tx": { + "default": "mdi:upload" + }, + "port_bandwidth_rx": { + "default": "mdi:download" + }, + "port_bandwidth_tx": { + "default": "mdi:upload" + } + }, + "switch": { + "block_client": { + "default": "mdi:ethernet", + "state": { + "off": "mdi:ethernet-off" + } + }, + "dpi_restriction": { + "default": "mdi:network", + "state": { + "off": "mdi:network-off" + } + }, + "port_forward_control": { + "default": "mdi:upload-network" + }, + "traffic_rule_control": { + "default": "mdi:security-network" + }, + "poe_port_control": { + "default": "mdi:ethernet", + "state": { + "off": "mdi:ethernet-off" + } + }, + "wlan_control": { + "default": "mdi:wifi-check", + "state": { + "off": "mdi:wifi-off" + } + } + } + }, "services": { "reconnect_client": { "service": "mdi:sync" diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 697df00fe55..2a3ed69a5f1 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -377,11 +377,11 @@ class UnifiSensorEntityDescription( ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor RX", + translation_key="client_bandwidth_rx", device_class=SensorDeviceClass.DATA_RATE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - icon="mdi:upload", allowed_fn=async_bandwidth_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, device_info_fn=async_client_device_info_fn, @@ -394,11 +394,11 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor TX", + translation_key="client_bandwidth_tx", device_class=SensorDeviceClass.DATA_RATE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - icon="mdi:download", allowed_fn=async_bandwidth_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, device_info_fn=async_client_device_info_fn, @@ -427,13 +427,13 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Ports, Port]( key="Port Bandwidth sensor RX", + translation_key="port_bandwidth_rx", device_class=SensorDeviceClass.DATA_RATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, - icon="mdi:download", allowed_fn=lambda hub, _: hub.config.option_allow_bandwidth_sensors, api_handler_fn=lambda api: api.ports, available_fn=async_device_available_fn, @@ -445,13 +445,13 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Ports, Port]( key="Port Bandwidth sensor TX", + translation_key="port_bandwidth_tx", device_class=SensorDeviceClass.DATA_RATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, - icon="mdi:upload", allowed_fn=lambda hub, _: hub.config.option_allow_bandwidth_sensors, api_handler_fn=lambda api: api.ports, available_fn=async_device_available_fn, diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 2af610480fc..01843a8a95b 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -194,9 +194,9 @@ class UnifiSwitchEntityDescription( ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( UnifiSwitchEntityDescription[Clients, Client]( key="Block client", + translation_key="block_client", device_class=SwitchDeviceClass.SWITCH, entity_category=EntityCategory.CONFIG, - icon="mdi:ethernet", allowed_fn=async_block_client_allowed_fn, api_handler_fn=lambda api: api.clients, control_fn=async_block_client_control_fn, @@ -210,9 +210,9 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ), UnifiSwitchEntityDescription[DPIRestrictionGroups, DPIRestrictionGroup]( key="DPI restriction", + translation_key="dpi_restriction", has_entity_name=False, entity_category=EntityCategory.CONFIG, - icon="mdi:network", allowed_fn=lambda hub, obj_id: hub.config.option_dpi_restrictions, api_handler_fn=lambda api: api.dpi_groups, control_fn=async_dpi_group_control_fn, @@ -239,9 +239,9 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ), UnifiSwitchEntityDescription[PortForwarding, PortForward]( key="Port forward control", + translation_key="port_forward_control", device_class=SwitchDeviceClass.SWITCH, entity_category=EntityCategory.CONFIG, - icon="mdi:upload-network", api_handler_fn=lambda api: api.port_forwarding, control_fn=async_port_forward_control_fn, device_info_fn=async_unifi_network_device_info_fn, @@ -252,9 +252,9 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ), UnifiSwitchEntityDescription[TrafficRules, TrafficRule]( key="Traffic rule control", + translation_key="traffic_rule_control", device_class=SwitchDeviceClass.SWITCH, entity_category=EntityCategory.CONFIG, - icon="mdi:security-network", api_handler_fn=lambda api: api.traffic_rules, control_fn=async_traffic_rule_control_fn, device_info_fn=async_unifi_network_device_info_fn, @@ -265,10 +265,10 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ), UnifiSwitchEntityDescription[Ports, Port]( key="PoE port control", + translation_key="poe_port_control", device_class=SwitchDeviceClass.OUTLET, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:ethernet", api_handler_fn=lambda api: api.ports, available_fn=async_device_available_fn, control_fn=async_poe_port_control_fn, @@ -281,9 +281,9 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ), UnifiSwitchEntityDescription[Wlans, Wlan]( key="WLAN control", + translation_key="wlan_control", device_class=SwitchDeviceClass.SWITCH, entity_category=EntityCategory.CONFIG, - icon="mdi:wifi-check", api_handler_fn=lambda api: api.wlans, control_fn=async_wlan_control_fn, device_info_fn=async_wlan_device_info_fn, diff --git a/tests/components/unifi/snapshots/test_sensor.ambr b/tests/components/unifi/snapshots/test_sensor.ambr index 3053f69d616..9041d7ac63c 100644 --- a/tests/components/unifi/snapshots/test_sensor.ambr +++ b/tests/components/unifi/snapshots/test_sensor.ambr @@ -1088,12 +1088,12 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:download', + 'original_icon': None, 'original_name': 'Port 1 RX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'port_bandwidth_rx', 'unique_id': 'port_rx-10:00:00:00:01:01_1', 'unit_of_measurement': , }) @@ -1103,7 +1103,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'mock-name Port 1 RX', - 'icon': 'mdi:download', 'state_class': , 'unit_of_measurement': , }), @@ -1143,12 +1142,12 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:upload', + 'original_icon': None, 'original_name': 'Port 1 TX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'port_bandwidth_tx', 'unique_id': 'port_tx-10:00:00:00:01:01_1', 'unit_of_measurement': , }) @@ -1158,7 +1157,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'mock-name Port 1 TX', - 'icon': 'mdi:upload', 'state_class': , 'unit_of_measurement': , }), @@ -1249,12 +1247,12 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:download', + 'original_icon': None, 'original_name': 'Port 2 RX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'port_bandwidth_rx', 'unique_id': 'port_rx-10:00:00:00:01:01_2', 'unit_of_measurement': , }) @@ -1264,7 +1262,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'mock-name Port 2 RX', - 'icon': 'mdi:download', 'state_class': , 'unit_of_measurement': , }), @@ -1304,12 +1301,12 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:upload', + 'original_icon': None, 'original_name': 'Port 2 TX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'port_bandwidth_tx', 'unique_id': 'port_tx-10:00:00:00:01:01_2', 'unit_of_measurement': , }) @@ -1319,7 +1316,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'mock-name Port 2 TX', - 'icon': 'mdi:upload', 'state_class': , 'unit_of_measurement': , }), @@ -1359,12 +1355,12 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:download', + 'original_icon': None, 'original_name': 'Port 3 RX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'port_bandwidth_rx', 'unique_id': 'port_rx-10:00:00:00:01:01_3', 'unit_of_measurement': , }) @@ -1374,7 +1370,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'mock-name Port 3 RX', - 'icon': 'mdi:download', 'state_class': , 'unit_of_measurement': , }), @@ -1414,12 +1409,12 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:upload', + 'original_icon': None, 'original_name': 'Port 3 TX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'port_bandwidth_tx', 'unique_id': 'port_tx-10:00:00:00:01:01_3', 'unit_of_measurement': , }) @@ -1429,7 +1424,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'mock-name Port 3 TX', - 'icon': 'mdi:upload', 'state_class': , 'unit_of_measurement': , }), @@ -1520,12 +1514,12 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:download', + 'original_icon': None, 'original_name': 'Port 4 RX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'port_bandwidth_rx', 'unique_id': 'port_rx-10:00:00:00:01:01_4', 'unit_of_measurement': , }) @@ -1535,7 +1529,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'mock-name Port 4 RX', - 'icon': 'mdi:download', 'state_class': , 'unit_of_measurement': , }), @@ -1575,12 +1568,12 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:upload', + 'original_icon': None, 'original_name': 'Port 4 TX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'port_bandwidth_tx', 'unique_id': 'port_tx-10:00:00:00:01:01_4', 'unit_of_measurement': , }) @@ -1590,7 +1583,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'mock-name Port 4 TX', - 'icon': 'mdi:upload', 'state_class': , 'unit_of_measurement': , }), @@ -1801,12 +1793,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:upload', + 'original_icon': None, 'original_name': 'RX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'client_bandwidth_rx', 'unique_id': 'rx-00:00:00:00:00:01', 'unit_of_measurement': , }) @@ -1816,7 +1808,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'Wired client RX', - 'icon': 'mdi:upload', 'state_class': , 'unit_of_measurement': , }), @@ -1853,12 +1844,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:download', + 'original_icon': None, 'original_name': 'TX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'client_bandwidth_tx', 'unique_id': 'tx-00:00:00:00:00:01', 'unit_of_measurement': , }) @@ -1868,7 +1859,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'Wired client TX', - 'icon': 'mdi:download', 'state_class': , 'unit_of_measurement': , }), @@ -1952,12 +1942,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:upload', + 'original_icon': None, 'original_name': 'RX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'client_bandwidth_rx', 'unique_id': 'rx-00:00:00:00:00:02', 'unit_of_measurement': , }) @@ -1967,7 +1957,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'Wireless client RX', - 'icon': 'mdi:upload', 'state_class': , 'unit_of_measurement': , }), @@ -2004,12 +1993,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:download', + 'original_icon': None, 'original_name': 'TX', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'client_bandwidth_tx', 'unique_id': 'tx-00:00:00:00:00:02', 'unit_of_measurement': , }) @@ -2019,7 +2008,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'Wireless client TX', - 'icon': 'mdi:download', 'state_class': , 'unit_of_measurement': , }), diff --git a/tests/components/unifi/snapshots/test_switch.ambr b/tests/components/unifi/snapshots/test_switch.ambr index 04b15f329fd..87b485adaf2 100644 --- a/tests/components/unifi/snapshots/test_switch.ambr +++ b/tests/components/unifi/snapshots/test_switch.ambr @@ -1970,12 +1970,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:ethernet', + 'original_icon': None, 'original_name': None, 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'block_client', 'unique_id': 'block-00:00:00:00:01:01', 'unit_of_measurement': None, }) @@ -1985,7 +1985,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'switch', 'friendly_name': 'Block Client 1', - 'icon': 'mdi:ethernet', }), 'context': , 'entity_id': 'switch.block_client_1', @@ -2018,12 +2017,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:network', + 'original_icon': None, 'original_name': 'Block Media Streaming', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'dpi_restriction', 'unique_id': '5f976f4ae3c58f018ec7dff6', 'unit_of_measurement': None, }) @@ -2032,7 +2031,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Block Media Streaming', - 'icon': 'mdi:network', }), 'context': , 'entity_id': 'switch.block_media_streaming', @@ -2159,12 +2157,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:ethernet', + 'original_icon': None, 'original_name': 'Port 1 PoE', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'poe_port_control', 'unique_id': 'poe-10:00:00:00:01:01_1', 'unit_of_measurement': None, }) @@ -2174,7 +2172,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', 'friendly_name': 'mock-name Port 1 PoE', - 'icon': 'mdi:ethernet', }), 'context': , 'entity_id': 'switch.mock_name_port_1_poe', @@ -2207,12 +2204,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:ethernet', + 'original_icon': None, 'original_name': 'Port 2 PoE', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'poe_port_control', 'unique_id': 'poe-10:00:00:00:01:01_2', 'unit_of_measurement': None, }) @@ -2222,7 +2219,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', 'friendly_name': 'mock-name Port 2 PoE', - 'icon': 'mdi:ethernet', }), 'context': , 'entity_id': 'switch.mock_name_port_2_poe', @@ -2255,12 +2251,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:ethernet', + 'original_icon': None, 'original_name': 'Port 4 PoE', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'poe_port_control', 'unique_id': 'poe-10:00:00:00:01:01_4', 'unit_of_measurement': None, }) @@ -2270,7 +2266,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', 'friendly_name': 'mock-name Port 4 PoE', - 'icon': 'mdi:ethernet', }), 'context': , 'entity_id': 'switch.mock_name_port_4_poe', @@ -2350,12 +2345,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:wifi-check', + 'original_icon': None, 'original_name': None, 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'wlan_control', 'unique_id': 'wlan-012345678910111213141516', 'unit_of_measurement': None, }) @@ -2365,7 +2360,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'switch', 'friendly_name': 'SSID 1', - 'icon': 'mdi:wifi-check', }), 'context': , 'entity_id': 'switch.ssid_1', @@ -2398,12 +2392,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:upload-network', + 'original_icon': None, 'original_name': 'plex', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'port_forward_control', 'unique_id': 'port_forward-5a32aa4ee4b0412345678911', 'unit_of_measurement': None, }) @@ -2413,7 +2407,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'switch', 'friendly_name': 'UniFi Network plex', - 'icon': 'mdi:upload-network', }), 'context': , 'entity_id': 'switch.unifi_network_plex', @@ -2446,12 +2439,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:security-network', + 'original_icon': None, 'original_name': 'Test Traffic Rule', 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'traffic_rule_control', 'unique_id': 'traffic_rule-6452cd9b859d5b11aa002ea1', 'unit_of_measurement': None, }) @@ -2461,7 +2454,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'switch', 'friendly_name': 'UniFi Network Test Traffic Rule', - 'icon': 'mdi:security-network', }), 'context': , 'entity_id': 'switch.unifi_network_test_traffic_rule', From b606b50cec671e25be5eb039e8fc0ea3303777de Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 27 Sep 2024 17:28:51 +0200 Subject: [PATCH 040/134] Do not unsubscribe mqtt integration discovery if entry is already configured (#126907) * Do not unsubscribe mqtt integration discovery if entry is already configured * Test cases without unsubscribe --- homeassistant/components/mqtt/discovery.py | 3 +- tests/components/mqtt/test_discovery.py | 37 ++++++++++++++-------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 7707b8e5f49..e2a726e2915 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -393,8 +393,7 @@ async def async_start( # noqa: C901 if ( result and result["type"] == FlowResultType.ABORT - and result["reason"] - in ("already_configured", "single_instance_allowed") + and result["reason"] == "single_instance_allowed" ): integration_unsubscribe.pop(key)() diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 7f58fc75dae..2f83c1138b9 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1444,8 +1444,19 @@ async def test_complex_discovery_topic_prefix( @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.UNSUBSCRIBE_COOLDOWN", 0.0) +@pytest.mark.parametrize( + ("reason", "unsubscribes"), + [ + ("single_instance_allowed", True), + ("already_configured", False), + ("some_abort_error", False), + ], +) async def test_mqtt_integration_discovery_subscribe_unsubscribe( - hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + reason: str, + unsubscribes: bool, ) -> None: """Check MQTT integration discovery subscribe and unsubscribe.""" @@ -1454,7 +1465,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: """Test mqtt step.""" - return self.async_abort(reason="already_configured") + return self.async_abort(reason=reason) mock_platform(hass, "comp.config_flow", None) @@ -1465,13 +1476,6 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( """Handle birth message.""" birth.set() - wait_unsub = asyncio.Event() - - @callback - def _mock_unsubscribe(topics: list[str]) -> tuple[int, int]: - wait_unsub.set() - return (0, 0) - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=ENTRY_DEFAULT_BIRTH_MESSAGE) entry.add_to_hass(hass) with ( @@ -1480,7 +1484,6 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( return_value={"comp": ["comp/discovery/#"]}, ), mock_config_flow("comp", TestFlow), - patch.object(mqtt_client_mock, "unsubscribe", side_effect=_mock_unsubscribe), ): assert await hass.config_entries.async_setup(entry.entry_id) await mqtt.async_subscribe(hass, "homeassistant/status", wait_birth) @@ -1493,8 +1496,16 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( await hass.async_block_till_done(wait_background_tasks=True) async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") - await wait_unsub.wait() - mqtt_client_mock.unsubscribe.assert_called_once_with(["comp/discovery/#"]) + await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + unsubscribes + and call(["comp/discovery/#"]) in mqtt_client_mock.unsubscribe.mock_calls + or not unsubscribes + and call(["comp/discovery/#"]) + not in mqtt_client_mock.unsubscribe.mock_calls + ) await hass.async_block_till_done(wait_background_tasks=True) @@ -1513,7 +1524,7 @@ async def test_mqtt_discovery_unsubscribe_once( async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: """Test mqtt step.""" await asyncio.sleep(0) - return self.async_abort(reason="already_configured") + return self.async_abort(reason="single_instance_allowed") mock_platform(hass, "comp.config_flow", None) From 4e3b012f3eb9fba9972c3ce009c168841e289b99 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 27 Sep 2024 14:36:29 +0200 Subject: [PATCH 041/134] Fix Tado unloading (#126910) --- homeassistant/components/tado/__init__.py | 59 ++++++------------- .../components/tado/binary_sensor.py | 2 +- homeassistant/components/tado/climate.py | 2 +- homeassistant/components/tado/const.py | 4 -- .../components/tado/device_tracker.py | 2 +- homeassistant/components/tado/sensor.py | 4 +- homeassistant/components/tado/services.py | 3 +- homeassistant/components/tado/water_heater.py | 2 +- 8 files changed, 23 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 084819d8e68..cc5dee77617 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -1,9 +1,7 @@ """Support for the (unofficial) Tado API.""" -from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any import requests.exceptions @@ -22,9 +20,6 @@ from .const import ( CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TADO_OPTIONS, DOMAIN, - UPDATE_LISTENER, - UPDATE_MOBILE_DEVICE_TRACK, - UPDATE_TRACK, ) from .services import setup_services from .tado_connector import TadoConnector @@ -55,17 +50,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -type TadoConfigEntry = ConfigEntry[TadoRuntimeData] - - -@dataclass -class TadoRuntimeData: - """Dataclass for Tado runtime data.""" - - tadoconnector: TadoConnector - update_track: Any - update_mobile_device_track: Any - update_listener: Any +type TadoConfigEntry = ConfigEntry[TadoConnector] async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: @@ -99,26 +84,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool await hass.async_add_executor_job(tadoconnector.update) # Poll for updates in the background - update_track = async_track_time_interval( - hass, - lambda now: tadoconnector.update(), - SCAN_INTERVAL, + entry.async_on_unload( + async_track_time_interval( + hass, + lambda now: tadoconnector.update(), + SCAN_INTERVAL, + ) ) - update_mobile_devices = async_track_time_interval( - hass, - lambda now: tadoconnector.update_mobile_devices(), - SCAN_MOBILE_DEVICE_INTERVAL, + entry.async_on_unload( + async_track_time_interval( + hass, + lambda now: tadoconnector.update_mobile_devices(), + SCAN_MOBILE_DEVICE_INTERVAL, + ) ) - update_listener = entry.add_update_listener(_async_update_listener) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - entry.runtime_data = TadoRuntimeData( - tadoconnector=tadoconnector, - update_track=update_track, - update_mobile_device_track=update_mobile_devices, - update_listener=update_listener, - ) + entry.runtime_data = tadoconnector await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -147,15 +131,6 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - hass.data[DOMAIN][entry.entry_id][UPDATE_TRACK]() - hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER]() - hass.data[DOMAIN][entry.entry_id][UPDATE_MOBILE_DEVICE_TRACK]() - - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index ec8eb9331ac..25c1c801155 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -121,7 +121,7 @@ async def async_setup_entry( ) -> None: """Set up the Tado sensor platform.""" - tado: TadoConnector = entry.runtime_data.tadoconnector + tado = entry.runtime_data devices = tado.devices zones = tado.zones entities: list[BinarySensorEntity] = [] diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 60096c25301..21a09086d46 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -105,7 +105,7 @@ async def async_setup_entry( ) -> None: """Set up the Tado climate platform.""" - tado: TadoConnector = entry.runtime_data.tadoconnector + tado = entry.runtime_data entities = await hass.async_add_executor_job(_generate_entities, tado) platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 8033a653325..bdc4bff1943 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -38,8 +38,6 @@ TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = { CONF_FALLBACK = "fallback" CONF_HOME_ID = "home_id" DATA = "data" -UPDATE_TRACK = "update_track" -UPDATE_MOBILE_DEVICE_TRACK = "update_mobile_device_track" # Weather CONDITIONS_MAP = { @@ -207,8 +205,6 @@ DEFAULT_NAME = "Tado" TADO_HOME = "Home" TADO_ZONE = "Zone" -UPDATE_LISTENER = "update_listener" - # Constants for Temperature Offset INSIDE_TEMPERATURE_MEASUREMENT = "INSIDE_TEMPERATURE_MEASUREMENT" TEMP_OFFSET = "temperatureOffset" diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 08e610aead2..c1f7623dd64 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -28,7 +28,7 @@ async def async_setup_entry( ) -> None: """Set up the Tado device scannery entity.""" _LOGGER.debug("Setting up Tado device scanner entity") - tado: TadoConnector = entry.runtime_data.tadoconnector + tado = entry.runtime_data tracked: set = set() # Fix non-string unique_id for device trackers diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index e5e2948b3a9..8bb13a02cd1 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -71,10 +71,8 @@ def get_automatic_geofencing(data: dict[str, str]) -> bool: def get_geofencing_mode(data: dict[str, str]) -> str: """Return Geofencing Mode based on Presence and Presence Locked attributes.""" - tado_mode = "" tado_mode = data.get("presence", "unknown") - geofencing_switch_mode = "" if "presenceLocked" in data: if data["presenceLocked"]: geofencing_switch_mode = "manual" @@ -199,7 +197,7 @@ async def async_setup_entry( ) -> None: """Set up the Tado sensor platform.""" - tado: TadoConnector = entry.runtime_data.tadoconnector + tado = entry.runtime_data zones = tado.zones entities: list[SensorEntity] = [] diff --git a/homeassistant/components/tado/services.py b/homeassistant/components/tado/services.py index 8401f1925eb..89711808066 100644 --- a/homeassistant/components/tado/services.py +++ b/homeassistant/components/tado/services.py @@ -15,7 +15,6 @@ from .const import ( DOMAIN, SERVICE_ADD_METER_READING, ) -from .tado_connector import TadoConnector _LOGGER = logging.getLogger(__name__) SCHEMA_ADD_METER_READING = vol.Schema( @@ -44,7 +43,7 @@ def setup_services(hass: HomeAssistant) -> None: if entry is None: raise ServiceValidationError("Config entry not found") - tadoconnector: TadoConnector = entry.runtime_data.tadoconnector + tadoconnector = entry.runtime_data response: dict = await hass.async_add_executor_job( tadoconnector.set_meter_reading, call.data[CONF_READING] diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 896c10acf67..6c964cfaddd 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -67,7 +67,7 @@ async def async_setup_entry( ) -> None: """Set up the Tado water heater platform.""" - tado: TadoConnector = entry.runtime_data.tadoconnector + tado = entry.runtime_data entities = await hass.async_add_executor_job(_generate_entities, tado) platform = entity_platform.async_get_current_platform() From 7925aee91f104498685cf697906515dac4efcfe5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 27 Sep 2024 14:35:08 +0200 Subject: [PATCH 042/134] Migrate Nexia unique id to str (#126911) --- homeassistant/components/nexia/__init__.py | 18 ++++++++++++++++ homeassistant/components/nexia/config_flow.py | 3 ++- tests/components/nexia/test_init.py | 21 +++++++++++++++++++ tests/components/nexia/util.py | 5 ++++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index 9bc76fdcfdc..66a8ec5bdb8 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -86,3 +86,21 @@ async def async_remove_config_entry_device( if zone_id in dev_ids: return False return True + + +async def async_migrate_entry(hass: HomeAssistant, entry: NexiaConfigEntry) -> bool: + """Migrate entry.""" + + _LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + # 1 -> 2: Unique ID from integer to string + if entry.minor_version == 1: + minor_version = 2 + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.unique_id), minor_version=minor_version + ) + + _LOGGER.debug("Migration successful") + + return True diff --git a/homeassistant/components/nexia/config_flow.py b/homeassistant/components/nexia/config_flow.py index 592ebde61c3..85d8db03d7c 100644 --- a/homeassistant/components/nexia/config_flow.py +++ b/homeassistant/components/nexia/config_flow.py @@ -81,6 +81,7 @@ class NexiaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Nexia.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -99,7 +100,7 @@ class NexiaConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" if "base" not in errors: - await self.async_set_unique_id(info["house_id"]) + await self.async_set_unique_id(str(info["house_id"])) self._abort_if_unique_id_configured() return self.async_create_entry(title=info["title"], data=user_input) diff --git a/tests/components/nexia/test_init.py b/tests/components/nexia/test_init.py index 5984a0af721..4e5c5118d6b 100644 --- a/tests/components/nexia/test_init.py +++ b/tests/components/nexia/test_init.py @@ -1,15 +1,19 @@ """The init tests for the nexia platform.""" +from unittest.mock import patch + import aiohttp from homeassistant.components.nexia.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .util import async_init_integration +from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -48,3 +52,20 @@ async def test_device_remove_devices( ) response = await client.remove_device(dead_device_entry.id, entry_id) assert response["success"] + + +async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: + """Test migrating a 1.1 config entry to 1.2.""" + with patch("homeassistant.components.nexia.async_setup_entry", return_value=True): + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}, + version=1, + minor_version=1, + unique_id=123456, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == "123456" diff --git a/tests/components/nexia/util.py b/tests/components/nexia/util.py index 98d5312f0a1..1104ffad63d 100644 --- a/tests/components/nexia/util.py +++ b/tests/components/nexia/util.py @@ -54,7 +54,10 @@ async def async_init_integration( text=load_fixture(set_fan_speed_fixture), ) entry = MockConfigEntry( - domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} + domain=DOMAIN, + data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}, + minor_version=2, + unique_id="123456", ) entry.add_to_hass(hass) From 222006d1063ae1389b4b9022be3ef4c842307a6d Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Fri, 27 Sep 2024 13:56:37 +0100 Subject: [PATCH 043/134] Update `pytouchlinesl` to 0.1.6 (#126912) --- homeassistant/components/touchline_sl/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json index 8a50b06d613..99f28a79a41 100644 --- a/homeassistant/components/touchline_sl/manifest.json +++ b/homeassistant/components/touchline_sl/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/touchline_sl", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pytouchlinesl==0.1.5"] + "requirements": ["pytouchlinesl==0.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 90fccb44004..870e9119f3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2413,7 +2413,7 @@ pytomorrowio==0.3.6 pytouchline==0.7 # homeassistant.components.touchline_sl -pytouchlinesl==0.1.5 +pytouchlinesl==0.1.6 # homeassistant.components.traccar # homeassistant.components.traccar_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 57edf801448..702cdc2aab1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1919,7 +1919,7 @@ pytile==2023.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline_sl -pytouchlinesl==0.1.5 +pytouchlinesl==0.1.6 # homeassistant.components.traccar # homeassistant.components.traccar_server From 46d3bda80a6d35591c81f87afe6d9bdac0dba347 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Fri, 27 Sep 2024 15:43:10 +0200 Subject: [PATCH 044/134] Bump pyotgw to 2.2.1 (#126918) --- homeassistant/components/opentherm_gw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opentherm_gw/manifest.json b/homeassistant/components/opentherm_gw/manifest.json index b6ebef6e83c..927f9c9ca3e 100644 --- a/homeassistant/components/opentherm_gw/manifest.json +++ b/homeassistant/components/opentherm_gw/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/opentherm_gw", "iot_class": "local_push", "loggers": ["pyotgw"], - "requirements": ["pyotgw==2.2.0"] + "requirements": ["pyotgw==2.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 870e9119f3d..1de98d118a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2119,7 +2119,7 @@ pyoppleio-legacy==1.0.8 pyosoenergyapi==1.1.4 # homeassistant.components.opentherm_gw -pyotgw==2.2.0 +pyotgw==2.2.1 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 702cdc2aab1..ee2b1a4aec5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1703,7 +1703,7 @@ pyopnsense==0.4.0 pyosoenergyapi==1.1.4 # homeassistant.components.opentherm_gw -pyotgw==2.2.0 +pyotgw==2.2.1 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp From ba8e9bc1687361ae2c782f99dff2608865d3c095 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Fri, 27 Sep 2024 15:01:59 +0100 Subject: [PATCH 045/134] Bump `pytouchlinesl` to `0.1.7` (#126923) --- homeassistant/components/touchline_sl/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json index 99f28a79a41..2329cb67e17 100644 --- a/homeassistant/components/touchline_sl/manifest.json +++ b/homeassistant/components/touchline_sl/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/touchline_sl", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pytouchlinesl==0.1.6"] + "requirements": ["pytouchlinesl==0.1.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1de98d118a5..0b5a96d18d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2413,7 +2413,7 @@ pytomorrowio==0.3.6 pytouchline==0.7 # homeassistant.components.touchline_sl -pytouchlinesl==0.1.6 +pytouchlinesl==0.1.7 # homeassistant.components.traccar # homeassistant.components.traccar_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee2b1a4aec5..ee64a826ad1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1919,7 +1919,7 @@ pytile==2023.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline_sl -pytouchlinesl==0.1.6 +pytouchlinesl==0.1.7 # homeassistant.components.traccar # homeassistant.components.traccar_server From 02e15a4ce78ecf6b4734a998734b655ee60c8f68 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 27 Sep 2024 10:11:23 -0500 Subject: [PATCH 046/134] Change Assist satellite state names (#126926) * Change state names * Update homeassistant/components/assist_satellite/strings.json --------- Co-authored-by: Joost Lekkerkerker --- .../components/assist_satellite/entity.py | 18 +++++++-------- .../components/assist_satellite/strings.json | 4 ++-- .../assist_satellite/test_entity.py | 22 +++++++++---------- .../esphome/test_assist_satellite.py | 10 ++++----- tests/components/voip/test_voip.py | 8 +++---- 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 23b588b569e..ba8b54f7da2 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -41,10 +41,10 @@ _LOGGER = logging.getLogger(__name__) class AssistSatelliteState(StrEnum): """Valid states of an Assist satellite entity.""" - LISTENING_WAKE_WORD = "listening_wake_word" - """Device is streaming audio for wake word detection to Home Assistant.""" + IDLE = "idle" + """Device is waiting for user input, such as a wake word or a button press.""" - LISTENING_COMMAND = "listening_command" + LISTENING = "listening" """Device is streaming audio with the voice command to Home Assistant.""" PROCESSING = "processing" @@ -117,7 +117,7 @@ class AssistSatelliteEntity(entity.Entity): _attr_tts_options: dict[str, Any] | None = None _pipeline_task: asyncio.Task | None = None - __assist_satellite_state = AssistSatelliteState.LISTENING_WAKE_WORD + __assist_satellite_state = AssistSatelliteState.IDLE @final @property @@ -242,7 +242,7 @@ class AssistSatelliteEntity(entity.Entity): ) finally: self._is_announcing = False - self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD) + self._set_state(AssistSatelliteState.IDLE) async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: """Announce media on the satellite. @@ -363,9 +363,9 @@ class AssistSatelliteEntity(entity.Entity): def _internal_on_pipeline_event(self, event: PipelineEvent) -> None: """Set state based on pipeline stage.""" if event.type is PipelineEventType.WAKE_WORD_START: - self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD) + self._set_state(AssistSatelliteState.IDLE) elif event.type is PipelineEventType.STT_START: - self._set_state(AssistSatelliteState.LISTENING_COMMAND) + self._set_state(AssistSatelliteState.LISTENING) elif event.type is PipelineEventType.INTENT_START: self._set_state(AssistSatelliteState.PROCESSING) elif event.type is PipelineEventType.INTENT_END: @@ -379,7 +379,7 @@ class AssistSatelliteEntity(entity.Entity): self._set_state(AssistSatelliteState.RESPONDING) elif event.type is PipelineEventType.RUN_END: if not self._run_has_tts: - self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD) + self._set_state(AssistSatelliteState.IDLE) self.on_pipeline_event(event) @@ -392,7 +392,7 @@ class AssistSatelliteEntity(entity.Entity): @callback def tts_response_finished(self) -> None: """Tell entity that the text-to-speech response has finished playing.""" - self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD) + self._set_state(AssistSatelliteState.IDLE) @callback def _resolve_pipeline(self) -> str | None: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 1d07882daae..7f1426ef529 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -4,8 +4,8 @@ "_": { "name": "Assist satellite", "state": { - "listening_wake_word": "Wake word", - "listening_command": "Voice command", + "idle": "[%key:common::state::idle%]", + "listening": "Listening", "responding": "Responding", "processing": "Processing" } diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index b2347184bec..884ba36782c 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -37,7 +37,7 @@ async def test_entity_state( state = hass.states.get(ENTITY_ID) assert state is not None - assert state.state == AssistSatelliteState.LISTENING_WAKE_WORD + assert state.state == AssistSatelliteState.IDLE context = Context() audio_stream = object() @@ -73,18 +73,18 @@ async def test_entity_state( assert kwargs["end_stage"] == PipelineStage.TTS for event_type, event_data, expected_state in ( - (PipelineEventType.RUN_START, {}, AssistSatelliteState.LISTENING_WAKE_WORD), - (PipelineEventType.RUN_END, {}, AssistSatelliteState.LISTENING_WAKE_WORD), + (PipelineEventType.RUN_START, {}, AssistSatelliteState.IDLE), + (PipelineEventType.RUN_END, {}, AssistSatelliteState.IDLE), ( PipelineEventType.WAKE_WORD_START, {}, - AssistSatelliteState.LISTENING_WAKE_WORD, + AssistSatelliteState.IDLE, ), - (PipelineEventType.WAKE_WORD_END, {}, AssistSatelliteState.LISTENING_WAKE_WORD), - (PipelineEventType.STT_START, {}, AssistSatelliteState.LISTENING_COMMAND), - (PipelineEventType.STT_VAD_START, {}, AssistSatelliteState.LISTENING_COMMAND), - (PipelineEventType.STT_VAD_END, {}, AssistSatelliteState.LISTENING_COMMAND), - (PipelineEventType.STT_END, {}, AssistSatelliteState.LISTENING_COMMAND), + (PipelineEventType.WAKE_WORD_END, {}, AssistSatelliteState.IDLE), + (PipelineEventType.STT_START, {}, AssistSatelliteState.LISTENING), + (PipelineEventType.STT_VAD_START, {}, AssistSatelliteState.LISTENING), + (PipelineEventType.STT_VAD_END, {}, AssistSatelliteState.LISTENING), + (PipelineEventType.STT_END, {}, AssistSatelliteState.LISTENING), (PipelineEventType.INTENT_START, {}, AssistSatelliteState.PROCESSING), ( PipelineEventType.INTENT_END, @@ -105,7 +105,7 @@ async def test_entity_state( entity.tts_response_finished() state = hass.states.get(ENTITY_ID) - assert state.state == AssistSatelliteState.LISTENING_WAKE_WORD + assert state.state == AssistSatelliteState.IDLE async def test_new_pipeline_cancels_pipeline( @@ -241,7 +241,7 @@ async def test_announce( target={"entity_id": "assist_satellite.test_entity"}, blocking=True, ) - assert entity.state == AssistSatelliteState.LISTENING_WAKE_WORD + assert entity.state == AssistSatelliteState.IDLE assert entity.announcements[0] == expected_params diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 43ca3c0a341..b2c44af2cf9 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -187,7 +187,7 @@ async def test_pipeline_api_audio( ) # Wake word - assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD + assert satellite.state == AssistSatelliteState.IDLE event_callback( PipelineEvent( @@ -242,7 +242,7 @@ async def test_pipeline_api_audio( VoiceAssistantEventType.VOICE_ASSISTANT_STT_START, {}, ) - assert satellite.state == AssistSatelliteState.LISTENING_COMMAND + assert satellite.state == AssistSatelliteState.LISTENING event_callback( PipelineEvent( @@ -761,7 +761,7 @@ async def test_pipeline_media_player( ) await tts_finished.wait() - assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD + assert satellite.state == AssistSatelliteState.IDLE async def test_timer_events( @@ -1214,7 +1214,7 @@ async def test_announce_message( blocking=True, ) await done.wait() - assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD + assert satellite.state == AssistSatelliteState.IDLE async def test_announce_media_id( @@ -1297,7 +1297,7 @@ async def test_announce_media_id( blocking=True, ) await done.wait() - assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD + assert satellite.state == AssistSatelliteState.IDLE mock_async_create_proxy_url.assert_called_once_with( hass, diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index a0e032b65cb..17af2748c1c 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -199,7 +199,7 @@ async def test_pipeline( assert voip_user_id # Satellite is muted until a call begins - assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD + assert satellite.state == AssistSatelliteState.IDLE done = asyncio.Event() @@ -251,7 +251,7 @@ async def test_pipeline( ) ) - assert satellite.state == AssistSatelliteState.LISTENING_COMMAND + assert satellite.state == AssistSatelliteState.LISTENING # Fake STT result event_callback( @@ -345,7 +345,7 @@ async def test_pipeline( satellite.transport = Mock() satellite.connection_made(satellite.transport) - assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD + assert satellite.state == AssistSatelliteState.IDLE # Ensure audio queue is cleared before pipeline starts satellite._audio_queue.put_nowait(bad_chunk) @@ -370,7 +370,7 @@ async def test_pipeline( await done.wait() # Finished speaking - assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD + assert satellite.state == AssistSatelliteState.IDLE async def test_stt_stream_timeout( From c4f189863c7da909db62abc35cba6fe806ba0058 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 27 Sep 2024 10:10:50 -0500 Subject: [PATCH 047/134] Adjust "Assist in progress" sensor in ESPHome (#126928) Adjust sensor --- homeassistant/components/esphome/assist_satellite.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index bfe07a24096..3acf64cef70 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -315,6 +315,10 @@ class EsphomeAssistSatellite( "code": event.data["code"], "message": event.data["message"], } + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: + if self._tts_streaming_task is None: + # No TTS + self.entry_data.async_set_assist_pipeline_state(False) self.cli.send_voice_assistant_event(event_type, data_to_send) @@ -413,7 +417,6 @@ class EsphomeAssistSatellite( # Run the pipeline _LOGGER.debug("Running pipeline from %s to %s", start_stage, end_stage) - self.entry_data.async_set_assist_pipeline_state(True) self._pipeline_task = self.config_entry.async_create_background_task( self.hass, self.async_accept_pipeline_from_satellite( @@ -443,7 +446,6 @@ class EsphomeAssistSatellite( def handle_pipeline_finished(self) -> None: """Handle when pipeline has finished running.""" - self.entry_data.async_set_assist_pipeline_state(False) self._stop_udp_server() _LOGGER.debug("Pipeline finished") @@ -561,6 +563,7 @@ class EsphomeAssistSatellite( # State change self.tts_response_finished() + self.entry_data.async_set_assist_pipeline_state(False) async def _wrap_audio_stream(self) -> AsyncIterable[bytes]: """Yield audio chunks from the queue until None.""" From 73deb076fe6026437912f45b58d553b0c75224c3 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 27 Sep 2024 12:50:30 -0400 Subject: [PATCH 048/134] Squeezebox - bump pysqueezebox dependency to 0.9.3 to restore favorites support (#126929) --- homeassistant/components/squeezebox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 88a5ce02bc0..d9c7ce5e1f7 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.9.2"] + "requirements": ["pysqueezebox==0.9.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b5a96d18d2..355dc06988d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2262,7 +2262,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.9.2 +pysqueezebox==0.9.3 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee64a826ad1..5d3a1873b5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1816,7 +1816,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.9.2 +pysqueezebox==0.9.3 # homeassistant.components.suez_water pysuez==0.2.0 From 2d1708e5e8c8b71d38a385186dffe3a9ac80bdfc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 27 Sep 2024 18:10:39 +0200 Subject: [PATCH 049/134] Update frontend to 20240927.0 (#126933) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 9c41488f10a..f67cb9426e7 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240926.0"] + "requirements": ["home-assistant-frontend==20240927.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 171a4db310f..3465b8ebd1c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20240926.0 +home-assistant-frontend==20240927.0 home-assistant-intents==2024.9.23 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 355dc06988d..679f5a95574 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1117,7 +1117,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20240926.0 +home-assistant-frontend==20240927.0 # homeassistant.components.conversation home-assistant-intents==2024.9.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d3a1873b5e..3715ad4ba54 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -943,7 +943,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20240926.0 +home-assistant-frontend==20240927.0 # homeassistant.components.conversation home-assistant-intents==2024.9.23 From 9a56381e28f1438c6de3d744700c6b1c5ce9c50e Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Fri, 27 Sep 2024 18:50:00 +0200 Subject: [PATCH 050/134] Add missing icons to unifi (#126934) --- homeassistant/components/unifi/button.py | 1 + homeassistant/components/unifi/icons.json | 28 +++++++++++++++++++ homeassistant/components/unifi/image.py | 1 + homeassistant/components/unifi/sensor.py | 6 ++++ .../unifi/snapshots/test_button.ambr | 2 +- .../unifi/snapshots/test_image.ambr | 2 +- .../unifi/snapshots/test_sensor.ambr | 18 ++++++------ 7 files changed, 47 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index c53f8be147f..25c6816d794 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -117,6 +117,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiButtonEntityDescription, ...] = ( ), UnifiButtonEntityDescription[Wlans, Wlan]( key="WLAN regenerate password", + translation_key="wlan_regenerate_password", device_class=ButtonDeviceClass.UPDATE, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json index 525d089d6d4..76990c1c4a1 100644 --- a/homeassistant/components/unifi/icons.json +++ b/homeassistant/components/unifi/icons.json @@ -1,5 +1,15 @@ { "entity": { + "button": { + "wlan_regenerate_password": { + "default": "mdi:form-textbox-password" + } + }, + "image": { + "wlan_qr_code": { + "default": "mdi:qrcode" + } + }, "sensor": { "client_bandwidth_rx": { "default": "mdi:download" @@ -12,6 +22,24 @@ }, "port_bandwidth_tx": { "default": "mdi:upload" + }, + "wlan_clients": { + "default": "mdi:account-multiple" + }, + "device_clients": { + "default": "mdi:account-multiple" + }, + "device_uplink_mac": { + "default": "mdi:ethernet" + }, + "device_state": { + "default": "mdi:lan-connect" + }, + "device_cpu_utilization": { + "default": "mdi:chip" + }, + "device_memory_utilization": { + "default": "mdi:memory" } }, "switch": { diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 426f2ce2884..1f54f56b194 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -49,6 +49,7 @@ class UnifiImageEntityDescription( ENTITY_DESCRIPTIONS: tuple[UnifiImageEntityDescription, ...] = ( UnifiImageEntityDescription[Wlans, Wlan]( key="WLAN QR Code", + translation_key="wlan_qr_code", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, api_handler_fn=lambda api: api.wlans, diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 2a3ed69a5f1..74d49db6e4e 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -478,6 +478,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Wlans, Wlan]( key="WLAN clients", + translation_key="wlan_clients", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, api_handler_fn=lambda api: api.wlans, @@ -490,6 +491,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Devices, Device]( key="Device clients", + translation_key="device_clients", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -579,6 +581,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Devices, Device]( key="Device Uplink MAC", + translation_key="device_uplink_mac", entity_category=EntityCategory.DIAGNOSTIC, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, @@ -592,6 +595,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Devices, Device]( key="Device State", + translation_key="device_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, api_handler_fn=lambda api: api.devices, @@ -605,6 +609,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Devices, Device]( key="Device CPU utilization", + translation_key="device_cpu_utilization", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -619,6 +624,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Devices, Device]( key="Device memory utilization", + translation_key="device_memory_utilization", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, diff --git a/tests/components/unifi/snapshots/test_button.ambr b/tests/components/unifi/snapshots/test_button.ambr index de305aee7eb..3729bd31cf0 100644 --- a/tests/components/unifi/snapshots/test_button.ambr +++ b/tests/components/unifi/snapshots/test_button.ambr @@ -27,7 +27,7 @@ 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'wlan_regenerate_password', 'unique_id': 'regenerate_password-012345678910111213141516', 'unit_of_measurement': None, }) diff --git a/tests/components/unifi/snapshots/test_image.ambr b/tests/components/unifi/snapshots/test_image.ambr index 0922320ed4d..32e1a5ff622 100644 --- a/tests/components/unifi/snapshots/test_image.ambr +++ b/tests/components/unifi/snapshots/test_image.ambr @@ -27,7 +27,7 @@ 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'wlan_qr_code', 'unique_id': 'qr_code-012345678910111213141516', 'unit_of_measurement': None, }) diff --git a/tests/components/unifi/snapshots/test_sensor.ambr b/tests/components/unifi/snapshots/test_sensor.ambr index 9041d7ac63c..fc86a57a294 100644 --- a/tests/components/unifi/snapshots/test_sensor.ambr +++ b/tests/components/unifi/snapshots/test_sensor.ambr @@ -29,7 +29,7 @@ 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'device_clients', 'unique_id': 'device_clients-20:00:00:00:01:01', 'unit_of_measurement': None, }) @@ -92,7 +92,7 @@ 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'device_state', 'unique_id': 'device_state-20:00:00:00:01:01', 'unit_of_measurement': None, }) @@ -359,7 +359,7 @@ 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'device_clients', 'unique_id': 'device_clients-01:02:03:04:05:ff', 'unit_of_measurement': None, }) @@ -408,7 +408,7 @@ 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'device_cpu_utilization', 'unique_id': 'cpu_utilization-01:02:03:04:05:ff', 'unit_of_measurement': '%', }) @@ -458,7 +458,7 @@ 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'device_memory_utilization', 'unique_id': 'memory_utilization-01:02:03:04:05:ff', 'unit_of_measurement': '%', }) @@ -573,7 +573,7 @@ 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'device_state', 'unique_id': 'device_state-01:02:03:04:05:ff', 'unit_of_measurement': None, }) @@ -684,7 +684,7 @@ 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'device_clients', 'unique_id': 'device_clients-10:00:00:00:01:01', 'unit_of_measurement': None, }) @@ -1638,7 +1638,7 @@ 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'device_state', 'unique_id': 'device_state-10:00:00:00:01:01', 'unit_of_measurement': None, }) @@ -1749,7 +1749,7 @@ 'platform': 'unifi', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'wlan_clients', 'unique_id': 'wlan_clients-012345678910111213141516', 'unit_of_measurement': None, }) From 28aff1a90ae5ffa9d446225a963a47b1108f7538 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Sep 2024 19:39:22 +0200 Subject: [PATCH 051/134] Bump version to 2024.10.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index dda328b0873..55b4029ccab 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index bd5f5f4a09c..f76a6bba3b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b2" +version = "2024.10.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 23a11dddb3c5834dc799c7a79e5a59c9becc6c61 Mon Sep 17 00:00:00 2001 From: ozadr1an Date: Sat, 28 Sep 2024 04:49:34 +1000 Subject: [PATCH 052/134] Bump nessclient to 1.1.2 (#125604) Co-authored-by: Franck Nijhof --- homeassistant/components/ness_alarm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/licenses.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json index e4c5b5fb344..c3bb4239048 100644 --- a/homeassistant/components/ness_alarm/manifest.json +++ b/homeassistant/components/ness_alarm/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/ness_alarm", "iot_class": "local_push", "loggers": ["nessclient"], - "requirements": ["nessclient==1.0.0"] + "requirements": ["nessclient==1.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 679f5a95574..04eabf86702 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1417,7 +1417,7 @@ nad-receiver==0.3.0 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==1.0.0 +nessclient==1.1.2 # homeassistant.components.netdata netdata==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3715ad4ba54..f4ccf1557ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1183,7 +1183,7 @@ myuplink==0.6.0 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==1.0.0 +nessclient==1.1.2 # homeassistant.components.nmap_tracker netmap==0.7.0.2 diff --git a/script/licenses.py b/script/licenses.py index 177fc8e4b25..f39dcf13c14 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -149,7 +149,6 @@ EXCEPTIONS = { "krakenex", # https://github.com/veox/python3-krakenex/pull/145 "ld2410-ble", # https://github.com/930913/ld2410-ble/pull/7 "maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48 - "nessclient", # https://github.com/nickw444/nessclient/pull/65 "neurio", # https://github.com/jordanh/neurio-python/pull/13 "nsw-fuel-api-client", # https://github.com/nickw444/nsw-fuel-api-client/pull/14 "pigpio", # https://github.com/joan2937/pigpio/pull/608 From 6f4a488308eda0a75eea363157049c053b97f6c8 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 27 Sep 2024 19:29:28 +0100 Subject: [PATCH 053/134] Bump python-kasa library to 0.7.4 (#126944) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index b655f2e646a..81506c41a6d 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -301,5 +301,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.7.3"] + "requirements": ["python-kasa[speedups]==0.7.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 04eabf86702..7a1fca8fb7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2340,7 +2340,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.3 +python-kasa[speedups]==0.7.4 # homeassistant.components.linkplay python-linkplay==0.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4ccf1557ed..373717cc549 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1861,7 +1861,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.3 +python-kasa[speedups]==0.7.4 # homeassistant.components.linkplay python-linkplay==0.0.12 From 105d7952fcaba5df616c74df9a1560c1e30850fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Sep 2024 16:10:01 -0500 Subject: [PATCH 054/134] Bump yarl to 1.13.1 (#126962) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3465b8ebd1c..230b4bcf512 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -63,7 +63,7 @@ uv==0.4.15 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.13.0 +yarl==1.13.1 zeroconf==0.135.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index f76a6bba3b2..81a32e0355c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.13.0", + "yarl==1.13.1", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 6fc605fd5ea..a9c695969b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,4 +42,4 @@ uv==0.4.15 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.13.0 +yarl==1.13.1 From f57ce96ff0a595ed8e9304a5db270239d11867ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Sep 2024 03:47:05 -0500 Subject: [PATCH 055/134] Bump aiohttp to 3.10.7 (#126970) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 230b4bcf512..9dd4410b4ea 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.1.0 aiohttp-fast-zlib==0.1.1 -aiohttp==3.10.6 +aiohttp==3.10.7 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 81a32e0355c..3afc0a8b244 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor "aiohasupervisor==0.1.0", - "aiohttp==3.10.6", + "aiohttp==3.10.7", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index a9c695969b9..603ad31f400 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.1.0 -aiohttp==3.10.6 +aiohttp==3.10.7 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From 3bb13f76fa711da7ebc051e08abeabce7f6fa9bb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 28 Sep 2024 11:00:20 +0200 Subject: [PATCH 056/134] Bump version to 2024.10.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 55b4029ccab..802f2d00b03 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 3afc0a8b244..033bfdbf279 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b3" +version = "2024.10.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From a68d7c9b9dc4e5fcdd47a535f9ef14a3890e8ed0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 28 Sep 2024 14:53:40 +0200 Subject: [PATCH 057/134] Add unique id to mold_indicator (#126990) --- homeassistant/components/mold_indicator/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 8d7842ff718..76b8d2aa147 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -90,6 +90,7 @@ async def async_setup_platform( outdoor_temp_sensor, indoor_humidity_sensor, calib_factor, + None, ) ], False, @@ -118,6 +119,7 @@ async def async_setup_entry( outdoor_temp_sensor, indoor_humidity_sensor, calib_factor, + entry.entry_id, ) ], False, @@ -141,10 +143,12 @@ class MoldIndicator(SensorEntity): outdoor_temp_sensor: str, indoor_humidity_sensor: str, calib_factor: float, + unique_id: str | None, ) -> None: """Initialize the sensor.""" self._state: str | None = None self._attr_name = name + self._attr_unique_id = unique_id self._indoor_temp_sensor = indoor_temp_sensor self._indoor_humidity_sensor = indoor_humidity_sensor self._outdoor_temp_sensor = outdoor_temp_sensor From fc97eb81510a6d57eb98cc93e4f75c7ba11c9417 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 30 Sep 2024 13:08:58 +0200 Subject: [PATCH 058/134] Workday raise issues only to next year (#126997) * Workday - raise issues only for current and next year * variable --- .../components/workday/binary_sensor.py | 45 ++++++++++--------- .../workday/snapshots/test_binary_sensor.ambr | 25 +++++++++++ .../components/workday/test_binary_sensor.py | 41 ++++++++++++++++- 3 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 tests/components/workday/snapshots/test_binary_sensor.ambr diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 33c2e249024..f4a2541a1d7 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -90,7 +90,7 @@ def _get_obj_holidays( obj_holidays: HolidayBase = country_holidays( country, subdiv=province, - years=year, + years=[year, year + 1], language=language, categories=set_categories, ) @@ -129,6 +129,7 @@ async def async_setup_entry( ) calc_add_holidays: list[str] = validate_dates(add_holidays) calc_remove_holidays: list[str] = validate_dates(remove_holidays) + next_year = dt_util.now().year + 1 # Add custom holidays try: @@ -152,26 +153,28 @@ async def async_setup_entry( LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) except KeyError as unmatched: LOGGER.warning("No holiday found matching %s", unmatched) - if dt_util.parse_date(remove_holiday): - async_create_issue( - hass, - DOMAIN, - f"bad_date_holiday-{entry.entry_id}-{slugify(remove_holiday)}", - is_fixable=True, - is_persistent=False, - severity=IssueSeverity.WARNING, - translation_key="bad_date_holiday", - translation_placeholders={ - CONF_COUNTRY: country if country else "-", - "title": entry.title, - CONF_REMOVE_HOLIDAYS: remove_holiday, - }, - data={ - "entry_id": entry.entry_id, - "country": country, - "named_holiday": remove_holiday, - }, - ) + if _date := dt_util.parse_date(remove_holiday): + if _date.year <= next_year: + # Only check and raise issues for current and next year + async_create_issue( + hass, + DOMAIN, + f"bad_date_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_date_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) else: async_create_issue( hass, diff --git a/tests/components/workday/snapshots/test_binary_sensor.ambr b/tests/components/workday/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..8ad2f37f360 --- /dev/null +++ b/tests/components/workday/snapshots/test_binary_sensor.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_only_repairs_for_current_next_year + dict({ + tuple( + 'workday', + 'bad_date_holiday-1-2024_08_15', + ): IssueRegistryItemSnapshot({ + 'created': , + 'dismissed_version': None, + 'domain': 'workday', + 'is_persistent': False, + 'issue_id': 'bad_date_holiday-1-2024_08_15', + }), + tuple( + 'workday', + 'bad_date_holiday-1-2025_08_15', + ): IssueRegistryItemSnapshot({ + 'created': , + 'dismissed_version': None, + 'domain': 'workday', + 'is_persistent': False, + 'issue_id': 'bad_date_holiday-1-2025_08_15', + }), + }) +# --- diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index a2718c00824..212c3e9d305 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -5,10 +5,18 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.workday.binary_sensor import SERVICE_CHECK_DATE -from homeassistant.components.workday.const import DOMAIN +from homeassistant.components.workday.const import ( + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, + DOMAIN, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.dt import UTC @@ -422,3 +430,34 @@ async def test_optional_category( state = hass.states.get("binary_sensor.workday_sensor") assert state is not None assert state.state == end_state + + +async def test_only_repairs_for_current_next_year( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test only repairs are raised for current and next year.""" + freezer.move_to(datetime(2024, 8, 15, 12, tzinfo=UTC)) + remove_dates = [ + # None of these dates are holidays + "2024-08-15", # Creates issue + "2025-08-15", # Creates issue + "2026-08-15", # No issue + ] + config = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": remove_dates, + "language": "de", + } + await init_integration(hass, config) + + assert len(issue_registry.issues) == 2 + assert issue_registry.issues == snapshot From aa5e8eaf19e1e41567ac68c3998458e0cccc9034 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Sep 2024 12:22:57 -0400 Subject: [PATCH 059/134] Exclude Text-to-Speech cache from backups (#127001) Text-to-speech cache doesn't need to be included in backups. --- homeassistant/components/backup/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index 9573d522b56..3909f423d41 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -13,4 +13,5 @@ EXCLUDE_FROM_BACKUP = [ "*.log", "backups/*.tar", "OZW_Log.txt", + "tts/*", ] From 8d09982f3be65878879686fdf70edd5a9ad858a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Sep 2024 20:53:54 -0500 Subject: [PATCH 060/134] Bump aiohttp to 3.10.8 (#127009) changelog: https://github.com/aio-libs/aiohttp/compare/v3.10.7...v3.10.8 Fixes a long standing cancellation leak on timeout --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9dd4410b4ea..ab0db6898a3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.1.0 aiohttp-fast-zlib==0.1.1 -aiohttp==3.10.7 +aiohttp==3.10.8 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 033bfdbf279..9aca656f116 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor "aiohasupervisor==0.1.0", - "aiohttp==3.10.7", + "aiohttp==3.10.8", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index 603ad31f400..98ba315294b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.1.0 -aiohttp==3.10.7 +aiohttp==3.10.8 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From 75363b609b28a6c24abf20d309965a78a96550e0 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sat, 28 Sep 2024 17:46:01 -0500 Subject: [PATCH 061/134] Don't log voice assistant config timeout error (#127010) Don't log config timeout error --- homeassistant/components/esphome/assist_satellite.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 3acf64cef70..44d4a16761d 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -133,7 +133,7 @@ class EsphomeAssistSatellite( # Empty config. Updated when added to HA. self._satellite_config = assist_satellite.AssistSatelliteConfiguration( - available_wake_words=[], active_wake_words=[], max_active_wake_words=0 + available_wake_words=[], active_wake_words=[], max_active_wake_words=1 ) @property @@ -179,7 +179,13 @@ class EsphomeAssistSatellite( async def _update_satellite_config(self) -> None: """Get the latest satellite configuration from the device.""" - config = await self.cli.get_voice_assistant_configuration(_CONFIG_TIMEOUT_SEC) + try: + config = await self.cli.get_voice_assistant_configuration( + _CONFIG_TIMEOUT_SEC + ) + except TimeoutError: + # Placeholder config will be used + return # Update available/active wake words self._satellite_config.available_wake_words = [ From 084c2d976e83307d664db31162adb792a7eb8c7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Sep 2024 08:13:10 -0500 Subject: [PATCH 062/134] Bump anyio to 4.6.0 (#127013) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ab0db6898a3..78760285793 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -99,7 +99,7 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.4.0 +anyio==4.6.0 h11==0.14.0 httpcore==1.0.5 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 29b78e1ed9f..3586a10a2fd 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -118,7 +118,7 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.4.0 +anyio==4.6.0 h11==0.14.0 httpcore==1.0.5 From daa13235e6c158c3fc37f6ef32858a11272825f4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 30 Sep 2024 07:05:12 +0200 Subject: [PATCH 063/134] Allow `null` / `None` value for non numeric mqtt sensor without warnings (#127032) Allow `null` / `None` value for mqtt sensor without warnings --- homeassistant/components/mqtt/sensor.py | 8 ++++++-- tests/components/mqtt/test_sensor.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 5b7fbe34b76..3046c957978 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -260,14 +260,18 @@ class MqttSensor(MqttEntity, RestoreSensor): msg.topic, ) return + + if payload == PAYLOAD_NONE: + self._attr_native_value = None + return + if self._numeric_state_expected: if payload == "": _LOGGER.debug("Ignore empty state from '%s'", msg.topic) - elif payload == PAYLOAD_NONE: - self._attr_native_value = None else: self._attr_native_value = payload return + if self.options and payload not in self.options: _LOGGER.warning( "Ignoring invalid option received on topic '%s', got '%s', allowed: %s", diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index a62c36404ca..555d1be5ed3 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -299,6 +299,17 @@ async def test_setting_sensor_to_long_state_via_mqtt_message( STATE_UNKNOWN, True, ), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), + sensor.SensorDeviceClass.TIMESTAMP, + "None", + STATE_UNKNOWN, + False, + ), ( help_custom_config( sensor.DOMAIN, From 8f47b63762383b0777325660a983f3a743e15f3e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 29 Sep 2024 17:12:27 +0200 Subject: [PATCH 064/134] Bump py-synologydsm-api to 2.5.3 (#127035) --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 5d42188357b..b85189715ef 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.5.2"], + "requirements": ["py-synologydsm-api==2.5.3"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index 7a1fca8fb7c..d5b82323870 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1695,7 +1695,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.5.2 +py-synologydsm-api==2.5.3 # homeassistant.components.zabbix py-zabbix==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 373717cc549..dc5e3fd9f11 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1387,7 +1387,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.5.2 +py-synologydsm-api==2.5.3 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From 4e11797d724af5c715501b366bc62886b9e99977 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 30 Sep 2024 03:51:41 -0700 Subject: [PATCH 065/134] Update local_calendar/todo to avoid blocking in the event loop (#127048) --- .../components/local_calendar/calendar.py | 54 +++++++++++------- homeassistant/components/local_todo/todo.py | 56 +++++++++++-------- 2 files changed, 65 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 66b3f80c19c..eb7b0c20d91 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import date, datetime, timedelta import logging from typing import Any @@ -74,6 +75,7 @@ class LocalCalendarEntity(CalendarEntity): """Initialize LocalCalendarEntity.""" self._store = store self._calendar = calendar + self._calendar_lock = asyncio.Lock() self._event: CalendarEvent | None = None self._attr_name = name self._attr_unique_id = unique_id @@ -110,8 +112,10 @@ class LocalCalendarEntity(CalendarEntity): async def async_create_event(self, **kwargs: Any) -> None: """Add a new event to calendar.""" event = _parse_event(kwargs) - EventStore(self._calendar).add(event) - await self._async_store() + async with self._calendar_lock: + event_store = EventStore(self._calendar) + await self.hass.async_add_executor_job(event_store.add, event) + await self._async_store() await self.async_update_ha_state(force_refresh=True) async def async_delete_event( @@ -124,15 +128,16 @@ class LocalCalendarEntity(CalendarEntity): range_value: Range = Range.NONE if recurrence_range == Range.THIS_AND_FUTURE: range_value = Range.THIS_AND_FUTURE - try: - EventStore(self._calendar).delete( - uid, - recurrence_id=recurrence_id, - recurrence_range=range_value, - ) - except EventStoreError as err: - raise HomeAssistantError(f"Error while deleting event: {err}") from err - await self._async_store() + async with self._calendar_lock: + try: + EventStore(self._calendar).delete( + uid, + recurrence_id=recurrence_id, + recurrence_range=range_value, + ) + except EventStoreError as err: + raise HomeAssistantError(f"Error while deleting event: {err}") from err + await self._async_store() await self.async_update_ha_state(force_refresh=True) async def async_update_event( @@ -147,16 +152,23 @@ class LocalCalendarEntity(CalendarEntity): range_value: Range = Range.NONE if recurrence_range == Range.THIS_AND_FUTURE: range_value = Range.THIS_AND_FUTURE - try: - EventStore(self._calendar).edit( - uid, - new_event, - recurrence_id=recurrence_id, - recurrence_range=range_value, - ) - except EventStoreError as err: - raise HomeAssistantError(f"Error while updating event: {err}") from err - await self._async_store() + + async with self._calendar_lock: + event_store = EventStore(self._calendar) + + def apply_edit() -> None: + event_store.edit( + uid, + new_event, + recurrence_id=recurrence_id, + recurrence_range=range_value, + ) + + try: + await self.hass.async_add_executor_job(apply_edit) + except EventStoreError as err: + raise HomeAssistantError(f"Error while updating event: {err}") from err + await self._async_store() await self.async_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index a5f40c26738..c496fd6b6ba 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -1,5 +1,6 @@ """A Local To-do todo platform.""" +import asyncio import datetime import logging @@ -130,6 +131,7 @@ class LocalTodoListEntity(TodoListEntity): """Initialize LocalTodoListEntity.""" self._store = store self._calendar = calendar + self._calendar_lock = asyncio.Lock() self._attr_name = name.capitalize() self._attr_unique_id = unique_id @@ -159,23 +161,28 @@ class LocalTodoListEntity(TodoListEntity): async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" todo = _convert_item(item) - self._new_todo_store().add(todo) - await self.async_save() + async with self._calendar_lock: + todo_store = self._new_todo_store() + await self.hass.async_add_executor_job(todo_store.add, todo) + await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_update_todo_item(self, item: TodoItem) -> None: """Update an item to the To-do list.""" todo = _convert_item(item) - self._new_todo_store().edit(todo.uid, todo) - await self.async_save() + async with self._calendar_lock: + todo_store = self._new_todo_store() + await self.hass.async_add_executor_job(todo_store.edit, todo.uid, todo) + await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_delete_todo_items(self, uids: list[str]) -> None: """Delete an item from the To-do list.""" store = self._new_todo_store() - for uid in uids: - store.delete(uid) - await self.async_save() + async with self._calendar_lock: + for uid in uids: + store.delete(uid) + await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_move_todo_item( @@ -184,23 +191,24 @@ class LocalTodoListEntity(TodoListEntity): """Re-order an item to the To-do list.""" if uid == previous_uid: return - todos = self._calendar.todos - item_idx: dict[str, int] = {itm.uid: idx for idx, itm in enumerate(todos)} - if uid not in item_idx: - raise HomeAssistantError( - "Item '{uid}' not found in todo list {self.entity_id}" - ) - if previous_uid and previous_uid not in item_idx: - raise HomeAssistantError( - "Item '{previous_uid}' not found in todo list {self.entity_id}" - ) - dst_idx = item_idx[previous_uid] + 1 if previous_uid else 0 - src_idx = item_idx[uid] - src_item = todos.pop(src_idx) - if dst_idx > src_idx: - dst_idx -= 1 - todos.insert(dst_idx, src_item) - await self.async_save() + async with self._calendar_lock: + todos = self._calendar.todos + item_idx: dict[str, int] = {itm.uid: idx for idx, itm in enumerate(todos)} + if uid not in item_idx: + raise HomeAssistantError( + "Item '{uid}' not found in todo list {self.entity_id}" + ) + if previous_uid and previous_uid not in item_idx: + raise HomeAssistantError( + "Item '{previous_uid}' not found in todo list {self.entity_id}" + ) + dst_idx = item_idx[previous_uid] + 1 if previous_uid else 0 + src_idx = item_idx[uid] + src_item = todos.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + todos.insert(dst_idx, src_item) + await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_save(self) -> None: From 90708061724048265d38a923678a6c475060e2cf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Sep 2024 20:29:18 +0200 Subject: [PATCH 066/134] Update ical to 8.2.0 (#126954) --- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 163ad91fb7c..4a09cdebc57 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==6.1.4", "oauth2client==4.1.3", "ical==8.1.1"] + "requirements": ["gcal-sync==6.1.4", "oauth2client==4.1.3", "ical==8.2.0"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 95c65089c79..83de2cb296a 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==8.1.1"] + "requirements": ["ical==8.2.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 313315a34f6..c126799c39d 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==8.1.1"] + "requirements": ["ical==8.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d5b82323870..a93ebc8301b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1158,7 +1158,7 @@ ibmiotf==0.3.4 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==8.1.1 +ical==8.2.0 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc5e3fd9f11..7f88156edee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==8.1.1 +ical==8.2.0 # homeassistant.components.ping icmplib==3.0 From b42848fd7a070e0c23d1fc35c46c6de259dfc2ba Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 30 Sep 2024 00:11:31 -0700 Subject: [PATCH 067/134] Bump gcal_sync to 6.1.5 (#127049) --- homeassistant/components/google/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 4a09cdebc57..288ccbd6899 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==6.1.4", "oauth2client==4.1.3", "ical==8.2.0"] + "requirements": ["gcal-sync==6.1.5", "oauth2client==4.1.3", "ical==8.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a93ebc8301b..353a2560869 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -945,7 +945,7 @@ gardena-bluetooth==1.4.3 gassist-text==0.0.11 # homeassistant.components.google -gcal-sync==6.1.4 +gcal-sync==6.1.5 # homeassistant.components.geniushub geniushub-client==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f88156edee..d02b613827d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -798,7 +798,7 @@ gardena-bluetooth==1.4.3 gassist-text==0.0.11 # homeassistant.components.google -gcal-sync==6.1.4 +gcal-sync==6.1.5 # homeassistant.components.geniushub geniushub-client==0.7.1 From 62629a0b343ddd947363f83e25fa3c15276841db Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Mon, 30 Sep 2024 10:17:44 +0300 Subject: [PATCH 068/134] Fix repair when integration does not exist (#127050) --- homeassistant/components/seventeentrack/repairs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/seventeentrack/repairs.py b/homeassistant/components/seventeentrack/repairs.py index 71616e98506..ce72960ea91 100644 --- a/homeassistant/components/seventeentrack/repairs.py +++ b/homeassistant/components/seventeentrack/repairs.py @@ -42,8 +42,8 @@ async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict ) -> RepairsFlow: """Create flow.""" - if issue_id.startswith("deprecate_sensor_"): - entry = hass.config_entries.async_get_entry(data["entry_id"]) - assert entry + if issue_id.startswith("deprecate_sensor_") and ( + entry := hass.config_entries.async_get_entry(data["entry_id"]) + ): return SensorDeprecationRepairFlow(entry) return ConfirmRepairFlow() From 0a18838fb04c59df9e1d4806e599d4259fe0f920 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Mon, 30 Sep 2024 12:45:54 +0300 Subject: [PATCH 069/134] Fix timestamp isoformat in seventeentrack (#127052) fix timestamp isoformat --- homeassistant/components/seventeentrack/services.py | 2 +- .../seventeentrack/snapshots/test_services.ambr | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 9a7a4d2d4b6..0833bc0a97b 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -89,7 +89,7 @@ def setup_services(hass: HomeAssistant) -> None: ATTR_TRACKING_NUMBER: package.tracking_number, ATTR_LOCATION: package.location, ATTR_STATUS: package.status, - ATTR_TIMESTAMP: package.timestamp, + ATTR_TIMESTAMP: package.timestamp.isoformat(), ATTR_INFO_TEXT: package.info_text, ATTR_FRIENDLY_NAME: package.friendly_name, } diff --git a/tests/components/seventeentrack/snapshots/test_services.ambr b/tests/components/seventeentrack/snapshots/test_services.ambr index 202c5a3d667..568acea33a5 100644 --- a/tests/components/seventeentrack/snapshots/test_services.ambr +++ b/tests/components/seventeentrack/snapshots/test_services.ambr @@ -10,7 +10,7 @@ 'origin_country': 'Belgium', 'package_type': 'Registered Parcel', 'status': 'Expired', - 'timestamp': datetime.datetime(2020, 8, 10, 10, 32, tzinfo=), + 'timestamp': '2020-08-10T10:32:00+00:00', 'tracking_info_language': 'Unknown', 'tracking_number': '123', }), @@ -22,7 +22,7 @@ 'origin_country': 'Belgium', 'package_type': 'Registered Parcel', 'status': 'In Transit', - 'timestamp': datetime.datetime(2020, 8, 10, 10, 32, tzinfo=), + 'timestamp': '2020-08-10T10:32:00+00:00', 'tracking_info_language': 'Unknown', 'tracking_number': '456', }), @@ -34,7 +34,7 @@ 'origin_country': 'Belgium', 'package_type': 'Registered Parcel', 'status': 'Delivered', - 'timestamp': datetime.datetime(2020, 8, 10, 10, 32, tzinfo=), + 'timestamp': '2020-08-10T10:32:00+00:00', 'tracking_info_language': 'Unknown', 'tracking_number': '789', }), @@ -52,7 +52,7 @@ 'origin_country': 'Belgium', 'package_type': 'Registered Parcel', 'status': 'In Transit', - 'timestamp': datetime.datetime(2020, 8, 10, 10, 32, tzinfo=), + 'timestamp': '2020-08-10T10:32:00+00:00', 'tracking_info_language': 'Unknown', 'tracking_number': '456', }), @@ -64,7 +64,7 @@ 'origin_country': 'Belgium', 'package_type': 'Registered Parcel', 'status': 'Delivered', - 'timestamp': datetime.datetime(2020, 8, 10, 10, 32, tzinfo=), + 'timestamp': '2020-08-10T10:32:00+00:00', 'tracking_info_language': 'Unknown', 'tracking_number': '789', }), From 22c85bf5f7a8ac33d1eb2b55a508243d1e27fcc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Sep 2024 02:01:41 -0500 Subject: [PATCH 070/134] Fix removing nulls when encoding events for PostgreSQL (#127053) --- .../components/recorder/db_schema.py | 5 ++-- tests/components/recorder/test_models.py | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 6ba9d971f2c..7e8343321c3 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -375,9 +375,8 @@ class EventData(Base): event: Event, dialect: SupportedDialect | None ) -> bytes: """Create shared_data from an event.""" - if dialect == SupportedDialect.POSTGRESQL: - bytes_result = json_bytes_strip_null(event.data) - bytes_result = json_bytes(event.data) + encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes + bytes_result = encoder(event.data) if len(bytes_result) > MAX_EVENT_DATA_BYTES: _LOGGER.warning( "Event data for %s exceed maximum size of %s bytes. " diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index c8ab64c7d89..9078b2e861c 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -21,6 +21,7 @@ from homeassistant.const import EVENT_STATE_CHANGED import homeassistant.core as ha from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.util import dt as dt_util +from homeassistant.util.json import json_loads def test_from_event_to_db_event() -> None: @@ -41,6 +42,18 @@ def test_from_event_to_db_event() -> None: assert event.as_dict() == db_event.to_native().as_dict() +def test_from_event_to_db_event_with_null() -> None: + """Test converting event to EventData with a null with PostgreSQL.""" + event = ha.Event( + "test_event", + {"some_data": "withnull\0terminator"}, + ) + dialect = SupportedDialect.POSTGRESQL + event_data = EventData.shared_data_bytes_from_event(event, dialect) + decoded = json_loads(event_data) + assert decoded["some_data"] == "withnull" + + def test_from_event_to_db_state() -> None: """Test converting event to db state.""" state = ha.State( @@ -78,6 +91,21 @@ def test_from_event_to_db_state_attributes() -> None: assert db_attrs.to_native() == attrs +def test_from_event_to_db_state_attributes_with_null() -> None: + """Test converting a state to StateAttributes with a null with PostgreSQL.""" + attrs = {"this_attr": "withnull\0terminator"} + state = ha.State("sensor.temperature", "18", attrs) + event = ha.Event( + EVENT_STATE_CHANGED, + {"entity_id": "sensor.temperature", "old_state": None, "new_state": state}, + context=state.context, + ) + dialect = SupportedDialect.POSTGRESQL + shared_attrs = StateAttributes.shared_attrs_bytes_from_event(event, dialect) + decoded = json_loads(shared_attrs) + assert decoded["this_attr"] == "withnull" + + def test_repr() -> None: """Test converting event to db state repr.""" attrs = {"this_attr": True} From 3ee85b3356626c5c683635b478ac6dffac6821e9 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Mon, 30 Sep 2024 08:57:06 +0200 Subject: [PATCH 071/134] Clarify excl/incl filter functionality for waze_travel_time (#127056) --- homeassistant/components/waze_travel_time/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index 507731fc973..f053f033307 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -23,12 +23,12 @@ "options": { "step": { "init": { - "description": "The `substring` inputs will allow you to force the integration to use a particular route or avoid a particular route in its time travel calculation.", + "description": "Some options will allow you to force the integration to use a particular route or avoid a particular route in its time travel calculation.", "data": { "units": "Units", "vehicle_type": "Vehicle Type", - "incl_filter": "Streetname which must be part of the Selected Route", - "excl_filter": "Streetname which must NOT be part of the Selected Route", + "incl_filter": "Exact streetname which must be part of the selected route", + "excl_filter": "Exact streetname which must NOT be part of the selected route", "realtime": "Realtime Travel Time?", "avoid_toll_roads": "Avoid Toll Roads?", "avoid_ferries": "Avoid Ferries?", From a8f25b1b931e2198949be4967456bad3fab4e02f Mon Sep 17 00:00:00 2001 From: Jon Caruana Date: Sun, 29 Sep 2024 23:36:30 -0700 Subject: [PATCH 072/134] Bump pylitejet to 0.6.3 (#127063) --- homeassistant/components/litejet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index 65dde31436d..3cff83707f5 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["pylitejet"], "quality_scale": "platinum", - "requirements": ["pylitejet==0.6.2"] + "requirements": ["pylitejet==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 353a2560869..61f23cf7bae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2023,7 +2023,7 @@ pylgnetcast==0.3.9 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.6.2 +pylitejet==0.6.3 # homeassistant.components.litterrobot pylitterbot==2023.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d02b613827d..16c9031d419 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1625,7 +1625,7 @@ pylgnetcast==0.3.9 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.6.2 +pylitejet==0.6.3 # homeassistant.components.litterrobot pylitterbot==2023.5.0 From 725c361e9c7287e02e3f5d7c319c4d22d5f23c8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Sep 2024 01:34:41 -0500 Subject: [PATCH 073/134] Add missing OUI to august (#127064) --- homeassistant/components/august/manifest.json | 4 ++++ homeassistant/generated/dhcp.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index e2c35fc155f..2be8da29257 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -16,6 +16,10 @@ "hostname": "connect", "macaddress": "2C9FFB*" }, + { + "hostname": "connect", + "macaddress": "789C85*" + }, { "hostname": "august*", "macaddress": "E076D0*" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 757c43c96a7..62d73a37566 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -27,6 +27,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "connect", "macaddress": "2C9FFB*", }, + { + "domain": "august", + "hostname": "connect", + "macaddress": "789C85*", + }, { "domain": "august", "hostname": "august*", From fa295b93a7e4314aa633912ae6b0678ed184228c Mon Sep 17 00:00:00 2001 From: Luca Dibattista <34377738+LucaDiba@users.noreply.github.com> Date: Mon, 30 Sep 2024 00:10:54 -0700 Subject: [PATCH 074/134] Fix Roomba help URL (#127065) Co-authored-by: Franck Nijhof --- homeassistant/components/roomba/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index 53ea9aa7c44..8cee43ab4aa 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -41,7 +41,9 @@ DEFAULT_OPTIONS = {CONF_CONTINUOUS: DEFAULT_CONTINUOUS, CONF_DELAY: DEFAULT_DELA MAX_NUM_DEVICES_TO_DISCOVER = 25 AUTH_HELP_URL_KEY = "auth_help_url" -AUTH_HELP_URL_VALUE = "https://www.home-assistant.io/integrations/roomba/#manually-retrieving-your-credentials" +AUTH_HELP_URL_VALUE = ( + "https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials" +) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: From dc79299301eb24275d7c51a0671e520c08a41001 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 30 Sep 2024 10:18:46 +0200 Subject: [PATCH 075/134] Update xknxproject to 3.8.0 (#127072) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 01950107801..aa0178b2c4a 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==3.2.0", - "xknxproject==3.7.1", + "xknxproject==3.8.0", "knx-frontend==2024.9.10.221729" ], "single_config_entry": true diff --git a/requirements_all.txt b/requirements_all.txt index 61f23cf7bae..5bb8a632854 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2992,7 +2992,7 @@ xiaomi-ble==0.32.0 xknx==3.2.0 # homeassistant.components.knx -xknxproject==3.7.1 +xknxproject==3.8.0 # homeassistant.components.fritz # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 16c9031d419..036164d97d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2381,7 +2381,7 @@ xiaomi-ble==0.32.0 xknx==3.2.0 # homeassistant.components.knx -xknxproject==3.7.1 +xknxproject==3.8.0 # homeassistant.components.fritz # homeassistant.components.rest From b8ed4499444b2f8140a341313fe11e9c48343eb9 Mon Sep 17 00:00:00 2001 From: Simon Goodall Date: Mon, 30 Sep 2024 11:06:48 +0100 Subject: [PATCH 076/134] Check "status" is present before access during device update (#127091) --- homeassistant/components/hive/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index 97f7a07237d..00a2116e268 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -127,5 +127,5 @@ class HiveSensorEntity(HiveEntity, SensorEntity): await self.hive.session.updateData(self.device) self.device = await self.hive.sensor.getSensor(self.device) self._attr_native_value = self.entity_description.fn( - self.device["status"]["state"] + self.device.get("status", {}).get("state") ) From a2cd17ef0a646109939feda4dd087a6ed9487318 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 30 Sep 2024 13:21:20 +0200 Subject: [PATCH 077/134] Make Laundrify unique id a string (#127092) --- .../components/laundrify/__init__.py | 22 +++++++++++++++++++ .../components/laundrify/config_flow.py | 3 ++- tests/components/laundrify/conftest.py | 3 ++- .../components/laundrify/test_config_flow.py | 1 + tests/components/laundrify/test_init.py | 19 ++++++++++++++++ 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index 33d66c7748e..b08624b6d23 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from laundrify_aio import LaundrifyAPI from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException @@ -14,6 +16,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_POLL_INTERVAL, DOMAIN from .coordinator import LaundrifyUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -51,3 +55,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate entry.""" + + _LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + # 1 -> 2: Unique ID from integer to string + if entry.minor_version == 1: + minor_version = 2 + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.unique_id), minor_version=minor_version + ) + + _LOGGER.debug("Migration successful") + + return True diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py index 5a608954321..22988af3241 100644 --- a/homeassistant/components/laundrify/config_flow.py +++ b/homeassistant/components/laundrify/config_flow.py @@ -29,6 +29,7 @@ class LaundrifyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for laundrify.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -64,7 +65,7 @@ class LaundrifyConfigFlow(ConfigFlow, domain=DOMAIN): else: entry_data = {CONF_ACCESS_TOKEN: access_token} - await self.async_set_unique_id(account_id) + await self.async_set_unique_id(str(account_id)) self._abort_if_unique_id_configured() # Create a new entry if it doesn't exist diff --git a/tests/components/laundrify/conftest.py b/tests/components/laundrify/conftest.py index d60fe3f090b..4a78a2e9025 100644 --- a/tests/components/laundrify/conftest.py +++ b/tests/components/laundrify/conftest.py @@ -41,6 +41,7 @@ async def laundrify_setup_config_entry( domain=DOMAIN, unique_id=VALID_ACCOUNT_ID, data={CONF_ACCESS_TOKEN: access_token}, + minor_version=2, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -54,7 +55,7 @@ def laundrify_api_fixture(hass_client: ClientSessionGenerator): with ( patch( "laundrify_aio.LaundrifyAPI.get_account_id", - return_value=VALID_ACCOUNT_ID, + return_value=1234, ), patch( "laundrify_aio.LaundrifyAPI.validate_token", diff --git a/tests/components/laundrify/test_config_flow.py b/tests/components/laundrify/test_config_flow.py index 656fadf087f..54e849f79d0 100644 --- a/tests/components/laundrify/test_config_flow.py +++ b/tests/components/laundrify/test_config_flow.py @@ -32,6 +32,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["data"] == { CONF_ACCESS_TOKEN: VALID_ACCESS_TOKEN, } + assert result["result"].unique_id == "1234" async def test_form_invalid_format(hass: HomeAssistant, laundrify_api_mock) -> None: diff --git a/tests/components/laundrify/test_init.py b/tests/components/laundrify/test_init.py index a23f1a3bc82..117da661e29 100644 --- a/tests/components/laundrify/test_init.py +++ b/tests/components/laundrify/test_init.py @@ -4,8 +4,11 @@ from laundrify_aio import exceptions from homeassistant.components.laundrify.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from .const import VALID_ACCESS_TOKEN + from tests.common import MockConfigEntry @@ -53,3 +56,19 @@ async def test_setup_entry_unload( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert laundrify_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: + """Test migrating a 1.1 config entry to 1.2.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCESS_TOKEN: VALID_ACCESS_TOKEN}, + version=1, + minor_version=1, + unique_id=123456, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == "123456" From b6af6ddea2ad317915a244661b08ae4b365e24d5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 30 Sep 2024 13:25:17 +0200 Subject: [PATCH 078/134] Bump yt-dlp to 2024.09.27 (#127096) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 2285d7bce7d..635ab5f6d40 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2024.08.06"], + "requirements": ["yt-dlp==2024.09.27"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 5bb8a632854..6cbcf9edb06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3032,7 +3032,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.08.06 +yt-dlp==2024.09.27 # homeassistant.components.zamg zamg==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 036164d97d9..c50ef895961 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2415,7 +2415,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.08.06 +yt-dlp==2024.09.27 # homeassistant.components.zamg zamg==0.3.6 From f3a72dda7bfca87daeb6ef0b0eb7a77f3d03229d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Sep 2024 14:14:01 +0200 Subject: [PATCH 079/134] Bump version to 2024.10.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 802f2d00b03..3dffa9e003f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 9aca656f116..27ef4a9ef06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b4" +version = "2024.10.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 25247de6a67f5f41114be18f0a016d783b492a28 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 30 Sep 2024 18:35:14 +0200 Subject: [PATCH 080/134] Bump zwave-js-server-python to 0.58.1 (#127114) * Bump zwave-js-server-python to 0.58.1 * Update tests --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 4 ++-- tests/components/zwave_js/test_trigger.py | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 9533c82f2c1..0fee480b093 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.58.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.58.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 6cbcf9edb06..06d742493cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3059,7 +3059,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.58.0 +zwave-js-server-python==0.58.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c50ef895961..eb6da53abbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2430,7 +2430,7 @@ zeversolar==0.3.1 zha==0.0.34 # homeassistant.components.zwave_js -zwave-js-server-python==0.58.0 +zwave-js-server-python==0.58.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index bb236ea9acb..f636401a942 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -524,7 +524,7 @@ async def test_add_node( data={ "source": "controller", "event": "inclusion started", - "secure": False, + "strategy": 2, }, ) client.driver.receive_event(event) @@ -1822,7 +1822,7 @@ async def test_replace_failed_node( data={ "source": "controller", "event": "inclusion started", - "secure": False, + "strategy": 2, }, ) client.driver.receive_event(event) diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 5822afe7b9f..8c345619a90 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -549,7 +549,7 @@ async def test_zwave_js_event( "config_entry_id": integration.entry_id, "event_source": "controller", "event": "inclusion started", - "event_data": {"secure": True}, + "event_data": {"strategy": 0}, }, "action": { "event": "controller_event_data_filter", @@ -667,7 +667,7 @@ async def test_zwave_js_event( data={ "source": "controller", "event": "inclusion started", - "secure": False, + "strategy": 2, }, ) client.driver.controller.receive_event(event) @@ -691,7 +691,7 @@ async def test_zwave_js_event( data={ "source": "controller", "event": "inclusion started", - "secure": True, + "strategy": 0, }, ) client.driver.controller.receive_event(event) From f0c3900842b427d8cf7754169a6eee066a02eb5c Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 30 Sep 2024 18:28:03 +0200 Subject: [PATCH 081/134] Update frontend to 20240930.0 (#127125) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index f67cb9426e7..decdf737e3d 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240927.0"] + "requirements": ["home-assistant-frontend==20240930.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 78760285793..bd7bab352c9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20240927.0 +home-assistant-frontend==20240930.0 home-assistant-intents==2024.9.23 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 06d742493cf..36044b544e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1117,7 +1117,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20240927.0 +home-assistant-frontend==20240930.0 # homeassistant.components.conversation home-assistant-intents==2024.9.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb6da53abbf..af6f47b9297 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -943,7 +943,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20240927.0 +home-assistant-frontend==20240930.0 # homeassistant.components.conversation home-assistant-intents==2024.9.23 From d3e60690956c1cf003da84a481ebc39408239b87 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 30 Sep 2024 19:00:37 +0200 Subject: [PATCH 082/134] Mark Reolink camera entities as unavailable when camera is offline (#127127) Co-authored-by: Franck Nijhof --- homeassistant/components/reolink/entity.py | 5 +++++ tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_switch.py | 10 ++++++++++ 3 files changed, 16 insertions(+) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index d73c3a9b6e6..d0a8f6dfc8d 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -155,6 +155,11 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): configuration_url=self._conf_url, ) + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._host.api.camera_online(self._channel) + async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 458bac5022b..79a63963bca 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -92,6 +92,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.camera_sw_version_update_required.return_value = False host_mock.camera_uid.return_value = TEST_UID_CAM + host_mock.camera_online.return_value = True host_mock.channel_for_uid.return_value = 0 host_mock.get_encoding.return_value = "h264" host_mock.firmware_update_available.return_value = False diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index 142075ca0b0..b2e82040ad4 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -17,6 +17,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -302,6 +303,15 @@ async def test_switch( reolink_connect.set_recording.reset_mock(side_effect=True) + reolink_connect.camera_online.return_value = False + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + reolink_connect.camera_online.return_value = True + async def test_host_switch( hass: HomeAssistant, From abd351e326da21456b8b1ab134db50b035df9850 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Sep 2024 19:53:38 +0200 Subject: [PATCH 083/134] Update RestrictedPython to 7.3 (#127130) --- homeassistant/components/python_script/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index 34b1d414915..594012dabb1 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["RestrictedPython==7.2"] + "requirements": ["RestrictedPython==7.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 36044b544e7..76fa06a3972 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -109,7 +109,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.1.0 # homeassistant.components.python_script -RestrictedPython==7.2 +RestrictedPython==7.3 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af6f47b9297..2d3bb326df0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -103,7 +103,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.1.0 # homeassistant.components.python_script -RestrictedPython==7.2 +RestrictedPython==7.3 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 From 1ce2b18aafbcaef37367fb47931a6cd5fba347e4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 30 Sep 2024 20:50:32 +0200 Subject: [PATCH 084/134] Allow negative calibration factor in mold_indicator (#127133) --- homeassistant/components/mold_indicator/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py index cc8f05c102d..ac85d7cc100 100644 --- a/homeassistant/components/mold_indicator/config_flow.py +++ b/homeassistant/components/mold_indicator/config_flow.py @@ -44,7 +44,7 @@ async def validate_duplicate( DATA_SCHEMA_OPTIONS = vol.Schema( { vol.Required(CONF_CALIBRATION_FACTOR): NumberSelector( - NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + NumberSelectorConfig(step=0.1, mode=NumberSelectorMode.BOX) ) } ) From e9dc09755e6ec9adde14a0b4ddb10a8d065f3f4a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Sep 2024 20:51:44 +0200 Subject: [PATCH 085/134] Bump version to 2024.10.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3dffa9e003f..78c5b0d1561 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 27ef4a9ef06..b4d6d03692b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b5" +version = "2024.10.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 60dfccb74796b6a8b2aa10f3438b30ca89ae1f7a Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:13:11 +0200 Subject: [PATCH 086/134] Roborock fix "selected map" when first map in list is selected (#127126) * avoid None when current_map = 0 * combine statements --- homeassistant/components/roborock/select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 2b24ac76104..3dfe0e72a7b 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -148,6 +148,6 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): @property def current_option(self) -> str | None: """Get the current status of the select entity from device_status.""" - if current_map := self.coordinator.current_map: + if (current_map := self.coordinator.current_map) is not None: return self.coordinator.maps[current_map].name return None From 6f5eac314395b39f7072bab33d7b389795d33afa Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 30 Sep 2024 21:30:28 +0200 Subject: [PATCH 087/134] Add config flow validation that calibration factor is not zero (#127136) * Add config flow validation that calibration factor is not zero * Add test --- .../components/mold_indicator/config_flow.py | 9 ++-- .../components/mold_indicator/strings.json | 6 +++ .../mold_indicator/test_config_flow.py | 46 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py index ac85d7cc100..96ccbe2f8ee 100644 --- a/homeassistant/components/mold_indicator/config_flow.py +++ b/homeassistant/components/mold_indicator/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_NAME, Platform from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, + SchemaFlowError, SchemaFlowFormStep, ) from homeassistant.helpers.selector import ( @@ -33,11 +34,13 @@ from .const import ( ) -async def validate_duplicate( +async def validate_input( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: """Validate already existing entry.""" handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001 + if user_input[CONF_CALIBRATION_FACTOR] == 0.0: + raise SchemaFlowError("calibration_is_zero") return user_input @@ -74,13 +77,13 @@ DATA_SCHEMA_CONFIG = vol.Schema( CONFIG_FLOW = { "user": SchemaFlowFormStep( schema=DATA_SCHEMA_CONFIG, - validate_user_input=validate_duplicate, + validate_user_input=validate_input, ), } OPTIONS_FLOW = { "init": SchemaFlowFormStep( DATA_SCHEMA_OPTIONS, - validate_user_input=validate_duplicate, + validate_user_input=validate_input, ) } diff --git a/homeassistant/components/mold_indicator/strings.json b/homeassistant/components/mold_indicator/strings.json index 2e34bcc1ba1..03c6a05546f 100644 --- a/homeassistant/components/mold_indicator/strings.json +++ b/homeassistant/components/mold_indicator/strings.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, + "error": { + "calibration_is_zero": "Calibration factor can't be zero." + }, "step": { "user": { "description": "Add Mold indicator helper", @@ -27,6 +30,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, + "error": { + "calibration_is_zero": "Calibration factor can't be zero." + }, "step": { "init": { "description": "Adjust the calibration factor as required", diff --git a/tests/components/mold_indicator/test_config_flow.py b/tests/components/mold_indicator/test_config_flow.py index 7a766be11f5..339cb3a02e7 100644 --- a/tests/components/mold_indicator/test_config_flow.py +++ b/tests/components/mold_indicator/test_config_flow.py @@ -89,6 +89,52 @@ async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) assert state is not None +async def test_calibration_factor_not_zero(hass: HomeAssistant) -> None: + """Test calibration factor is not zero.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 0.0, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "calibration_is_zero"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 1.0, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 1.0, + } + + async def test_entry_already_exist( hass: HomeAssistant, loaded_entry: MockConfigEntry ) -> None: From 1e0164a96af0414bf226918a79a0e37b1d40a34a Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 1 Oct 2024 04:16:06 -0600 Subject: [PATCH 088/134] Allows unload when unsupported devices vesync (#127153) Allows unload when unsupported devices --- homeassistant/components/vesync/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 04547d33dea..b6f263f3037 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -137,6 +137,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + hass.data.pop(DOMAIN) return unload_ok From 92023ecbe6cb13b8ed827c310d2a0c29f044b9cf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Oct 2024 12:25:06 +0200 Subject: [PATCH 089/134] Update assist_satellite connection test sound (#127183) --- .../assist_satellite/connection_test.mp3 | Bin 36780 -> 41232 bytes 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 homeassistant/components/assist_satellite/connection_test.mp3 diff --git a/homeassistant/components/assist_satellite/connection_test.mp3 b/homeassistant/components/assist_satellite/connection_test.mp3 old mode 100755 new mode 100644 index 5fd79ce86095ad7800c1684cb8392c0ff89d6175..ced3bedc68492b4b355465f6d96a8c869c740a56 GIT binary patch literal 41232 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 z zGVsgf3dMs{mHnNN%@M76)7G&h7{Y_Ycv+2+Qd(=g&Y$=_CWhM4oBGK78sDLOZN6<2RRFzh# z+Mz1$IkstNsdSdNWvc8sdNLUFY}7 zyZ*znIgn!aa9u9S@AJKl9gvCC>HtCGuXRHq<6Au#+yP8YZW;#v4AYrWQ z)co47cRVn~b;0m;+~ND`gY~@H)u^#dM*M~+L%ehGm!4G(y-4|iCl&2=BKH#aKR#Uh zBo{nfFFs`MSLv#Ji+j}O_BUdTT=0g%zS!~MS%;{`n^g)8)U@%pzAc#zFopf>%b54y zS^C-H)|fRDfE^ivveDtAB~hW7b|rQNx@L8)Ez#j`sU_?3&kc4ZETZ%pq%PqHQrRg+zZ&DdKbQXGr z%6UuwErOZ8qN<37#F%OBWvDT0UvcY)wiI5dYS>~fdGTa>6Fw~ZtT>CRT*y#Wq_3q; zl)5oMqvj3#K92=a<3;&CBP_3fCa6?qxqph7V4kH7-Z8YH&`8#_xAsC*l?2*$Xhl;u zj&JeSn={h4S*v0OSl1+DDk*&oZwSG1AbYWp%j@;rsQd4M>mpFq>5l(LrFLk%zm##H zg0nM#?01cTzWz3UP18v8$8XqCyj3DvK8K8;1B5^cu##`NYIY zk-_wLcc7MRUbYhJK(U5`5}wSuX1(_9{SUnnQ`e&T6yNaNs{Zle7Qm5zrd11{q5E4} z?UR<^T~+8LDI-yS`d~-3x%fT%cY)v%AK0Qz!ewga_Tjn}bs6`OOYCMuF@g;Kb6J%p z=C;h$=-DAr{9&NhPN%3U1seGV%;>TRnf-*QMLRmwvpuHBc|)TFMmtI!jMo_+;UBQB ze|%ysKka$_1Jn3q1J=Zb-v6aSN6pzz>9fQ)n;+{NG&b-5zKFejN|a^+&R3c<{LAaZ z9!6ux!$wMR{OSuV&%wAmL2&Eh(4u$r@tbA(7beGJ04JqjK4OND^6|ne{BR)Qd+VP( zij+?%BqalEES3>8JOtQ)z}JBkAb32TLcPe_gT{v5oi^O zjQ|nH%zZ=*1~yHRF`}FXO0P8s$S_|~B=mP4S=+i(W?+pEBgGr~-Y{dAV*7XL8oplq zVYd3a%i`qF+1LFf=M3@k4;J@isJO)ytH;s;TDp@R*$`ejRuH9ZoXdKxO^Tg3EguJn z{ImVF3NbVe4>@q+V&KlaM+-pTW;gcnr%o}a1_`TXtbr8{3X`b_(*#c2?V&VA^($t? zzzi;C#S^x#+Ry9*`nF$k&~ru04P+Hz8D#7AQpaW?l? zZhN~IG80MCK2r)y6B|SUNPuY?h2pi8E)Kbqhd!>dQbhELn#=ryTjk#^Q)6-q&Zuj>ITMrtCVdhf0+5K7p->wN%8u3O%g7^vsvoL;tv*^&Ywcl0_->YT;g%WYp|85!f!-t6mfdRIj`}Y?p}2o-Z5G zX{1oPks1+4Dn9(yJE38VQ+R0;wEM)GB zu5JXhE)@Zk7gk>r8AIL1-nU77mF9NEmjZAR8`}ULKnIVaRpxuKgjf%cNy2b#J#dzv z_bi%2&qs}}9HqyT*j*#HKri$Z#6`D%QYJ+Av>&&@^@TLT`V3y%+5ZopugD~mE8|E0 zRKwUgS|hOTCv@fg1E310ZLc5H>=I7OQRG6{9f*hQp3+xKTQ5#*X5%VaSk7UohdH&4b+RUOM4wWp_ZUh14}b7_sf=sk^CuikwStfM=tWQG8yp7@{sZc@9zM`$N%F1F?$tpPn30s|@Kmt>>BU2~g#Rr?f7U>>Bz`<`-y=XM>V^Bb%m5MUs4kmW98ceN zCVA#;A4gev!>74Pde1_07Bp6lu?%?OUkRS2(&Ft|h2rbTv9XM|oGfd#hPURPnC0EK zzCv7M#Gk_ue--NS(!PZf$%$uWQA#{pNxL@$iDvS@9H_J&-T@T6t%P|AS(?#_I*gWUD6=ul1cm zq&2^CspK}BS=nUxrM{-jv!QA>E2*pdtG8JAD2khgH-+M(+k~O=T7v*4yIDBm@HL{?GYch6EcRgfB9%J zjGIMDk$nC(dGdpG$yzdr*E8PSoN!&j|0H{pR5wV|oG7-fl0dG#>Q(kTXn*Htp`Rod=CjtYV&y$;Inpr7MnB65Iqyo*nM{+`60uTZ+S&a$yIs3P>fUc5 zekY1An-!a5Z|#Yx8WTq1#(Kzsa=E3tLr&(kNzuxUseqi?Xc zj`DB;)J=YtSJmEcp=?Hqg!wl4JTJWzvY+tD5e3kNCqjfUD)Nh2q^5Yg6*nV2^fFHt zU(@>b7)yxL^TD1J8hG$JZ&YrHGf9Bm#{5Uh4oONCQT#|A3s7sP4G6j|7Hu5pmie~i z8T#@+KY0(}OfJ|w`fW$bYEHHYY(Zkp*x0HlY>PTSX#JbSIb#eqj;Di!bJfStnr2{K zNNVv2PiPA$skQ|n|HoKjU|=4#piG#U4=~VPWe{n0uW7T5HgHKctENk)uP(g5=P?xI z>HQ@PIW{|YhS?WBnmrs=ktP%GlO$WKQ7!cC+t^yHu%rBsTExQ3HIC=D zd+G1)k2g(@s4V`V7cRxE?=wHq8KmJ%OcQxDm1^ympYKmMC(QvAH*aqc?3IHF1F3XM zS{s0*Bj#bD( z5a-Lz?QwL3A7)h-61POV>(ek2!)y7x0-)JS9(h>tv> z$C_T>y4v5mhKg_;Dng`WhOEqt@lM0=U5Z%sI=bLi9E-2h^|7r#nR~KDn3;)VtL*%m zzC=KVfr6BcvnUasUsCrW&D1pnbx5`|io$h;zqE?!S>T8(*Ed@p!@Eg++2+IN7k}-( zZXW*p&@vMrl}bum?`Ad7;69ftRNF1BePx$g2a_oV$3tO6a#>{&gi#HH>;UTwfk2vt z1>R-hB8L&|Pw&`3d#a$pPNgRxa~Ea$51*Uh6o(fEXK>#W<*Jk8$m|;?>uD(|LChf# z@S3rXdL~KbfKR4B+oZ?Q>J1OCx}QdtP6CPMM$qu8!&4xZS%LOY?>h^b6X6WMlG1+M zF{7>Rv~Y4Zq0g+GG>B+Ysf$rTlhx8I%i_pRmx5FF;zN}W0GRYtn{#Dj)Fr_MoG3SK zq#PWT%Q5VN^_WB9C>UOxrP2nc=pTqL!7icpdn*u>^XRp(Q|Aozr?jSzi7)o&nRC!nE6~hpt5)>APc>3VO+$XAJ^schf}9Te!Xicwwa9n zYUdKb7xR5$bN>t9$NP~$y1I|s(J!TK@PLr%QPR1wBO<63t_E}97$6Z7G`6ZD*{?;Uk#CyXIT>nghn zpi_?fJ{*e5Y260Xor56Vp+$o%)jQanwWLj}vT=~Xxlgo^AUlUweMA)NX^SYDG*eqM4rxfReZ$>xW@O--1)rjCGwK`SzTh-60jz<|82r7?lv;9EQCC zLq}b^W~nV*JjyN=|ALh6H-p(JawNtk~$Rd^mWc{podq zHw4#RjMhNO>}yArI4%4LL&#HG zZE%p<4@XJ|Ryv{MN5;8jEkefnjxh28D0xyjLOD0b1`AUZ&LLNyKmz)Q=Ur%O-V1H# zM&gq&PPVDiL^TMnB1}{ZowgE%Zl-3jT&8Fur~K$gVx#vGDyF<#l`7aY#OStjbod8n`q*#GWWyA#Tzg(nx5;k$hw(cX%`W zbmHNJR3uWUV*z2j0!@(c(;_qsI?UXERWpD>_*7|?8x3~J*A{C80J?d!hz`VKV=M=e z9g{r)9dMwPH+;t`Q1}Ouhz7Ow2(iA=P9`98%r%E}=F-NIN{aqo z6TCI*3JxtL?IAG|SZF&&Y&OtK?_3HKrzpw_c1K5ATy}iBV;6ILo_va9;wa*ubQ3~t z%jT4?=Mz?K(4`!rRb#)DUG?|t>Cd}`I?3=!=EBdz5eeH@^{#KyQIAy22C=PEw57BL zXBBfmRkTSW4h8yr|M2{clDg!g>74bd8p+P~`4LZD19RM;5P#Y%_iF$b%48uuE+spN zQT(St3KccyZ8fEu!lJ)wK=+m}Z2=5p9+|^Y`MsMX1ROS6XsBuz#8kQmvNaa!+_px{ zIxn~=V~tp^h>nrTB{5{n;n(Sm1kN^SvBxa83l(rgveKC~5=U$9=Ejz0?A~%56nT#( zkM(`-pWRWUoH_+>GfZ*51pvLC!&FEpfAJ&=%DgmQpWHYqe-a^qQp+|zE)JBMg;Ord zT{Hxj1RDxvC#o#$rI)O-{OxlL2~EbMCN)R4ik;8RMUtQXK74xxZO4@z zJJX)YnX^KvZ|{hu!S66!IEDL$kfOW3fi{G`el@{>^Bn_#q>_VB}t5}6Wm%cHED)* znkpf%ETu)-8nLS*tdR3@b|*N6Cl~`yeh}AEbW}P@56YB)lVp^XN|!<4(ddW6x&Fq4 zg<`7JL}z6)DpfX;a2zg0wJ~)4P(6)kmHJM)v9UCKKJ!iN-(0ka%PPn3-OWNbmVr;@ zuIw?&T#A*{A7Zf{*JyAQQ}$cE#xdqW3%VssJ6il3DV zDC@}iZ;e;R&s5?<`XQD9QyyFYQQxDEi#sygM8Zj$C#wAEp08`c;7QYhlCqL-uEgld zWB8xg76Ln>Ly!{*KAl^=LQ|2ge-gct;r+q5q183q;tJy_9MpfCMPAzcZD--4t~9qI zU#xtFLskrczWk{SCUpYbrp>z~G-WNMk)tV+z5@_}IjN8KBlguh4pM5^zz0Z($xuj@ zbzJw@?BK^RlktPl24B{17Akjg#=qQ5m2}X9XpJ)LU*puu!8C8hci7xoF+v=k-pUKb zE-~%mI(T_Odv)EXlqifrRDbo@{ZlHE`99AVOJb;yQsbKGG_L>Y;Mks3&NkWfBkGa@ zP`5@($dtkefZ@I|gu4JLSdl9fV*gk#dc-p{jZF{XLi(!Ok-cgv6(>!EOvRhnJI5A@ z>ySe+%Lh*5{GFX?-5m>!6mBhLVHd0lY@??V*n=e_g>7FA8okmdOZdi*Ulzo|Uy<6! z!8k`6yi1-l$vY7&M4OW!;3c@gr}(k!uN(m`eD|gQL(GBVHKI9mXp-YM(m$o_iWfLHmkpY61*vV)J2K~<GhN-v$K zB?Pu-L-9$ANRqCx6{)a{y5Oy>6cY3%Y32P|QFfEuK0` zUl|gF4-AWcpo2lV2j#);^k*pkbb?2gu+CyWMc?09HC}{9JVRyl-wbqJ!H^%a-?d&2 z+~!4BUM%@U{KiO3?B4s~*_^$L-7ko5U=brj#^uXlvgGy)_5;mHGMsL!^X+qH{!HVN zt>(WZ##G~+Gd`sN%F_otLZ2uRTt;wMl0@Io6fdT$+mIt;k<7ep%Jsi+Ymzn?uh{4;3s=2w;TvLSPGAxp%10q_s_CqAeY*k#UvDDjf`fevpr=L zDHh3i$w7K@Ew2UO+CvUpy`1jzpus^@%AnVL223|L@TX178+IY3SKzp%(QN0+H}c%w z$-9aiyfTL?Ildd;ZZbZ-`K9Az8Tk2t7{`m&vPq6BswQi&>`rB)S=mB3n=AqvJh@Ne zlTc}8UN8FQ@2Sb7oZQR9=nxCB-;2vN?9lu6ro5G_MT>oioDgbB_DrB6UcCwTh7!4X zoy=?%D(fNF^Sx)RZ7^f9@fsSWSI{tFs4?(rYUfeb zNkU5ZIeGIaQbY+oL$s_?e}q=uyOQlD^{xmMhykBXTu90%(Lr6ADAMg@Sp{8Ad7T)} zAr8v9V?Hj2^nB(`-OtJ4FEVHA1?gs#c<455eM7BZHqD$;<^6c?k~0*4W4equyxaWs zbF)2e9z2@+>tW^o&js)1$!ci3^N;y9%+-(4+RBkz!;*s*8i;gQpGtg7)Z_xpKkb50 z?P_QUW2hBj@_qduKEJ>ObSwxLxc?(Q)mRzilL!Ri(10RJ0t<2yiiMUIK^2Ok3Ost@ z0I3l|G%JAFQWGiz1xG;YQ9p&q>^ITT+3+id=)Xj^VWtquDA32#qsev@;1sRxMv@GD znHFn)w|1*2#4j)RD_e56Zt;g(xuv9jF`9Ct-AXu3|B%Vwh7Xjj@276<-WTu-xjt1> zk2Hs`oac-`T-c=4mhwmyQN+pQ*S;TinRXSttz-JzO*^$Fc{lL5swBGX*NcP84K5-& zS!Z;yZH5N*)h2cO6$g%K2^hct{MyTmQm8+b9~UzrD+?tyJB!RP;{!Eb6%s_sDvcxv z-a-pO3r4{bX|a`)nq51|CG9VVe&rfUfU4wkO^F&(_y8v2#0 zS>zi`|8*6sESHAxYCoT&as5Kk! z1I8qTz@chkcyPxv4z$fJXpvG7#6K~7l zE1p!wyZp}g?&}X-%b$52eAel{zRLOP_EE4w-|x%M#D|E11vh8a(!F42sP4S@007wj z9HG;*pi@ZOCci2df5ylQ70U{f9!ObqV2ii+l*K0YiKR~rk&|jjq|`$XE^>%bK~I3r zcG}beN<>*=ldY8TbWmpHg>sDFq2{i7Z8VjE6(Tyv)m;3HBG(5wD!ZA1)StplbV$QV zqz$?8oKhHi;*=%+DYH zRiV#cOiE+K&fci!%)>BKZjewGh`lnkEuV9<^od%IaIUOc$HjxGj4e^PW9Q_($$jPv zVh>euWdnSfZw_Rm2TCrzS8WM$qGB1t{TD4aGmm9(s>_k*%RoO2fone`8=0Vx6GsK0 zFVnN}H5L|j$TWy_j4Dt1|Ky~Gvh!NtN;METk7!`3(4wY*LAs;v(lJ#@JpeXiahL}X zN5!ycf`M14ie<&5Tf(cn@H8Ys%v8TNJA>kgm#S}b?a(XRSjg+`q44r^Oi=j;|M%~O z^L}L+Q%Wjgr*CN2s!W z-2L9A>1y}TR&Sf?4xsW545HysbHF_oP@lk}d1niCvk23avS?}hL!x5TD8<>a%|mQ5 zQ3%n{qY9)PkcAoYOqiFJtOM>JMKGEKzsa4Ok}|C6+>`I6Q&6ehIw!7DWS`a7Iqqb# zUQ0&{^$NRKQuB7q&4WfYXL^TX{oL+*XPsy3j3#sp4%Hbr7u3MFq$#3*Hm79bucWbW(RVkPSv$3O|~< zIl7d+lz2NUatwn^GmHS9BOa;rkqR7-E~IK9%q06WO0z6|JmQd4?MOw>Fw(EQheBlj zDvLvqzPnuT?F+&v*7}@7gYe_Gw^IS7ouOR;r=$-TzZgGr`3(Kdnw|Ro*dIw7Is&KD zaVfa;5;+={RK>R!`y5&57KI0z`n}o)tT$k!UQy-)N?K8*7m5_9$xlRVLt0=fGG9ZnAS(BLp```cC;Us_^RK`DGtWGo}wK)5idNLD8EYLGn?FBR1uexQP% z5JDU$Ur2@Gmy108H{acd5^(-fa`Cuiw79LblVo$1u4fzCL;)KB3=++mRN+?FA{1OyB2=^<(*s$BY8_| z+4eAgVDe+HrC!D7>EAy+btYj~QR$y=r>|PvnZMg#EIh0OxVGCfl$exod4LT?cYGS6 z!)!7%l>|0oO^2$6YETxJ%S&AXjk9tA zNj}Bio;67s1WnQyz)7&>2rytzET=8@$LN!}9Und5RcWE7CCY_U33+ikTn8DH;$YJ{Rd@*EH+n8{H`>mokbG4(P ztzo`)ukc2)%+1%i6Pp8m7YVMd&&*4YW^ga#-Uf?7v=n|wV9D*!vK>N+iNqEfEJR`F5 zsW#epRR9>$+oSVy63li&gqHE;YS(zDbUo!eUQpN@LK#&`S`J;EB2r(n*sIHu58_*ffrf-e17qS!%3W z1nGZ%*@}|SjdiX6&XHBbBrzB;{Bfp46+UUs&!t-}Ukv~S+udk7QIFdQaDm1+Y_7s- zEV$Je7@DoP2eY3~5g(H7>zmTQL(^d(8TvYi7PfFuULJPpS~h8VuaOm`Tl{#e$}}O( zR+z!n6(-}c_(55Ny{so1uf?M8lXEGnoF^)Coxk+E4YVAhn(>xv@B5}-9b>kF))yBK zh8e&8g*aD4e#y-m5qK}KAe-xb&DdJ~fQnT|oKRf|wXK1p5-VH&SY`4EM!INhEU9); z25OyqB?u!b-f%8hCH+aPLi}DIN0lJKO=|N$d@h6WOl}oiTyPw@O6@<_6*CHsCe{hp$5CC*Dv{K0aNMCRaES6`Bkq##{KW_M#%eoP?G?SX?n(*Z3!kBo6$G zG^7GI%TqsByq8ONJ}hbiIScs@Jy}5#<&tyRBKN1iT)5LJ3mfbHIPC7i5hl#gIr(KD zB#MqZY0pqCEQ-oN4B%iJQ^7#DqK~lQ-No82)RTpL;tOD4!3WES5-TZ3pMXfp<1GyM z!v53$&=g*<+&})% zokw#L`>|LpM82`OwS3xKM7T+blFEOJGX?@n`Kt_FUAfX+Q`0t=E0ewkjt?(x=}`HNG9`{yUp4BEghC5uUsLi93r3RM`9^%)*dEn7R87bEkpR?YX z9iD1kE}o{bt`tL~CN!WI&~*4hh~zmU0)i2aH;v0A{hYvk;<6tY z|1)IGO>SX{mLO$e9;FS%q<=yN;L4L4KocZc2a;wvwfv0Czb-kD{J6|+r;@2(LZG$5Jzc@>nLVsa?p(jsdN?j zqQDBmB0%O}UL_fR14eT2p+PVS_|cH|xsWcN!rJM6FJ(u}q-#7jP2#A)>K7AH*@3^2 zOmAs*@Ofp6*gm3?ULUGT3>hUi#eFHuA3#^oF4HlQ&`4QmPLlmN_Pji`_5Rv>{91R- z;B!kLrO<2YI{rIafM5f4CL||O=82MYU!=OdiB^HSlE}aqzYMIR<*%WI!N;>s}$nL^H7id`p~35 zw{p(sIP6UMmGOCHKJlzR&Esist?81V@@J`ovm}(&s~f6DYs0F-{IBE%xp+{I@!n8O zXHtHjP*Z&+HdH&Wmn>hJ44}Q(_HS+}yz1j`Dp9jZfNL5E!eACpBk(q$e)BSxuG6>-xu*fv@jpgv)YkK+4O?5Gn6zEie4Jzky~f6rvpZ!Op|4H7CQoE9uFBXZh*l}+Z@nEH_JO(p)3+K7u!Z;;t@bzfDI zlGhel4KP;C{&gkBMxTs`V)-VNeqOS^pV*j!bWMd#Ov&tx$|xFP{7%MeJvKrXPg>b1 zU8x@m^1Ktvk2wi0$3r$t?rZ7;xs>bjtD*Z>ZDq6oW@gv}u^~DMI!N)KUR{I|zI^tG z&({-;iX*jV;k2S6-sGcbJdap#76W@tCRPUlJuPLIBl-e9>A*X6C9PpKrrrA7mmFbd zZ6qF1PjS++BYLq0LMUGUyoEA$W@NukXfup<-$7{h-KN=@Ni`xNTin!-&X+B2^G_ZRDJ5 zv?3PYW%kJ!Nt@8)akKDrP-XR`T(rG_1`%le;-cszVNt{zS`2oj?qVZDvW%=rKk)Lj zm~AYEG2Y1R1x?5EfbOBDwI&bpdIldivFZ5f`E7$+(f+Ar0CPzmk z^@Y*T!L--QxGBpnjvXUawJ&H^E`%NeKRi6#W~>;rnFG3iP;s;IM_8o>Qqk-UIy1yA z26DWZHAOF&&2gfMkpJO%g+gHXOzym_lb)^OD88kgF*~>*D^0c78cGG{GdKo$?1G6+ z8&`*H*x%$7!CNvOYiZVT0qGI@ZLLJn5e#844!ztQ#8|xE-hngPNM*d=;P2=2Ej4$Y zd@?gXTi?{5Hx#Z6Y&m+|zW@8|E}xJ}!+Ul)hgh)Yx7qjK(^tpm9}?>h1GetcKd!1h zI*-G?{$g6C8uq_8hR-BYT0XZ}R6ayNq<|6|T zasD=34C>a2SKhnoX+dxz&}R)6rr%!WJpJ60t|N#hH8(PE5_z_77y`en7K*4U6)Pmq zVb4`OHGz3!$AH42>})0Bc5=F$%2w~0qbxB+SZJYPdS!n+zE^CWkUaNk^Ql|)%K<5% z>L-{K1%O+sge99mMTY88Dfq0>V3GQua$ac|N)vn#0{h3$yHEnPM}A&ivb(VyslAh! zilX59O_N1c>wy=ItL)0>sp6T30b%qNXIr>5&^r-Ky#jsYGFgIsRWFp>P~O;;E$|I~ z#h$w*?>0?)@P#LHc~b0|U+hDSm}B{ugqMUOj|FxoPiOe2 zEunlP`y4_50*;I@sV*lmT&QN5P?%TaZ+nDjIcj@mxyYPw>|FYCO<3k)9v_ zd~N6FYFX`uN7A9l#eA75wPn^K9 zWoOw>r;m&wsU)1BPJxCH2fKgwU;XYZE#rac+&BAAo)b2;clNK6xC$!U=(}r6Yg4~7 zoOE~gn2JV5(op-W8Zczo^a{I+n|sG?dHjiRys*z8!Y`zTHu>*VH1I1N`O#xv-qc@3 zso8vid2zp^qj%0P0_4buNjXR6CZwQm(WwC$5OviFd`K}IZ*Yi^k}0ud@z;^137_BJ ze0yEaU=XhcRA%Zo8Jh~B7{&dj)X^pXoRzEN*)1*)gTKwGiae2K52!MZB<6WnQ{#ds z&c9!_WY9B{@6+&a#!i3Fy&Qct%=H&d9&iF{D#HLDV~C5_9MkQeCcu_nXpwRHmVEWr zNusVM?}IJ)hpCs=e!TBPVo5`v(%C4XFZ##IoNW`X$`XUGqgtZLJ$);meUOgdLnROP zHtX-qyI=E5OTJY6Ql-j(TNuBs)l*m?_UEG}r6B0O`26Pafl8g$oXL}ifB*fgS;{*( z;4r_-oV6W%cgXM7@&Gv*)RsL8?|voU-Xs*T@^WUpb4W?R=C;cylNmXyo?IV)L-KkY zU5Pwzb#jo~derx5vGeTHwBCaTm+8ym>}vM-BvSDE#;LsNfItMoCgOftiAxcWW+ps= zQ)N2m8I_12D{f`rkqL{(MK5C9RGT8Q2P2pvJ1YZl1>(`Fy{uHJNBXd96H-FW)GUk5 zQ4_Jkh;b-RMD4E(XTCJb7t<$3@YG&4q8PT zp0UE~k~)$BCpTCzlmn`t?5s^9WHQnTP>ZmBtFHR5#iewdg5fx{Yu+o$+MMJN0IkGN z$T}#InvSngg&$T|kNZ*U1LOQ(#+ogm0NL{>%RK|0(?H&gm>2Um4;F;Ls5*@UU%90a zxpD!?q?wl#ArrHOL*v(u{A=AY*B8S7LJii!FItk@r3)12r{LlFZvoBOZcc@Jz`{b< za9-&?9VkX=11)OsGn8kFpHe_l+FPrDh`>Z(inZN^ea$!x1H;GX)==6MG?WjJD^vozVGvdhtx}c_u`)e7$>IaKf2hBqfV%g&g2xKuuHL~c5x@xQl-e3L6QlI7>C@D&%X@3tTp^y}^sZ(U| z`7hIX{^2lCLOPJ4;&j0yG`L+^{`>HnNlKGx{54Rd`}V>|K3 zVz?tEq^&Tl<3vUM6LcZ1EbPokEpBf)wYEIj;r_1Vsu3o_=3_QMDb_avXv|8J<_iOA zX~cETW>mGYg+*+~^(i8G1&-By`pTz0B~(e%wH zKRH8+D9ov=y5%yOo{cp#Io&6C;! zYEh2rSqxVMH8Z^@a~iSqw=(;W3>|l1VF^)rf-=Z1&slQkS0u{b9n>8zHEwNHzFW~P zt=qL6bI3iVQ#>Y{_Sul_)#o(xKVEJ+9D+VSOz|v^C2&8)hav@b=G-st4Apgw(o?I8 zODP8e?1A8cMq4P{-{y54qKwGkE`LXq_6fWwA)ItjgMCPdQ?Wxpu}H=RDPxfQfN~n| z>nCpuFF@n{!Oz?IJ?uVpSjr>=HXo?nAT8Lb3B|J9zm$cjbEA{BcR=xaEBSB;ZMOW*_cGY#4~7fTfq|_zK`# z+1zXp=tILB0Rz#u`O+SU40R!Q_~VF$ zfq7T6K)Q6OMq&2I*N8J?(l4~dQOEXeqWS!r{Jzz?ofiaK)%lCVSCbpZi49GOJI%+Y zG0h7Ow(*)Kv$MUfwVM+0HIEl&tDULdjAoqgdtqIY-V#VA)&xY(=A~~3f>T0u4d}q( zf=biL!!kq|&;l%)eQSXnpoOG5AZ`4bly*ovbz=2Q@xU5&%YuMN?k8ETbl-%f_$SU@ zp(~>a>WSS+i$AO1*P8FYH)^=MW0q(<<^Ol8b@UHcR#ubMvS;^4mU-pt5?;fA73P-Q z?cCFaBhS;s!iNXJ1Tpx4MVc{%0?;HOcj)Bw&6{(ZqPC;-l1Zr?imxEorlCMA6mEw1 zT>|Ir*8$jhWT;}jc1;a`ck-sWh5(OOikC>lC#5CnCf$%a3;byg)0~U}_guDvN(Bqc z{`o)xriIy6zFWNg1;GM;%elE9aZ@u_SYYMpnz)xilKA%HaMeIs*~MHU7%LXAB$_p8 z5r$_yM3u6&+x2^*Ii^^)Yh*C@^Z{T2*jxOSBl=~vTDT&X4U_V3M& zjb%0G21h&r3M6;S$YJ-p?FRpbO&9)_)%bD5HBnHJ zl2}{fEv^)w#0n6^GQ`MG>dO(j-YNcJzw3zex)?7ihH!0&E+XV6VLqX>URlNG6IuLd zNoH!YaW-$9jitF)3iWwP0MsOhs6og&{0Wy!@~Fb-bP#>{$_F-y-5l}eZ}b%n+xrh{ zKWse7!j`-pUr*KmpA+JDxW;sxo)rcOIvenkSOX5$av3(&`}(q}Y*D!o4%^=74RY() zca721!C7nV5`XxuUmA_5*q>ZT1dR=BCl^@WxGp|F4XDK}<2mdwuA02FcV=W*y2GKB zQde6oZ;1hJg3+1Wy@?CAcsK|ny}6LD`9b9TA;+nufW5TFyB_^H?%S>v-%r===LRqU zys}0>$!RZ$1Y)&?Ajx4C;Ry&I>GU#A&@jnjb?ai3IfOP#FwWA?8me|+ z-E#NFzbX?IMTL+-qlvOZ%`+6VOV411Yc=}gP7zr;4l8*XdE1pdoc8iB=(4F=Rc@1~x z%47NpizLBPC=MGfClLS`u8^gGiX88kOHt3QdQBlf*%4srhHA30pEUM%K-431OM>jG z?H4R9chcGAY=-P|F3a2Q36 zZ9?u<;}l?i3GqUXGCnsXN|G*tiHPdwsxSo8qe#jBPXdTB`5GZjTaSeO5;Pywc z&YJ9Q`^d-8H%~dTtZzLE_$UeN<$BMD~HxhCykn_=?mNsbekmZ>h^C7j`Nll?QWYz(4k~RnA6&V zKWO6sB=Rx{w)jV2W4jpUROe$;I9FN`?jr)Q%f|UcZsOrP|QvGgp zxDY3)5K$o|qxTnkNjF3~aZ-p*>ag#(^Z1az0_SbDn8X>x!m^mxq-ns3um3*_Q;n6? zIR^T1eEwB-d$TVliysvTkDpNyeq4P`nsEq#%loJmcyax}5Ox`;`2X-g8Z)h^;m(KH z$>nrTk}hFRl<^4WnlBC5c%iDJ3& zyJ^wzURsR7>p_{xME48;0vcnQfasp<6JSndaICljEm>BYt?y{W2}?OGfDmh)wvk{w zTqFs3Yqc5&0P-J%F^G28zOnLPpl>W$OLZ^UbV9Q>JeyfDD3@2Wcyz1 z4PW9D=|6}C{vutcrCx0AptCfCVLPzRIi*)V-2O!u08SA?lI|T)oDauMSK7P3VZR=YkUhi zSjU*~j&vcOu0T+q;GphLRdqmi_O+YQMNZPU_e+d&FYw;d1ehXC6XGzmFS>UI#~C?7 zPXp)FM|nJRk8=ld=f674!a*mUkxbq*!N(~=pek; zt0r8bXj;`*&w@>3X`}Wz2!CNH`&GC}>?8jcgs|^dQXbeAF*$fB>uRhkx%18N-D}KMj=X3{_*Kd z&wOZ|Xv7QrF6Wa`LnwIL2B<+W%=}LIp&9=0w*oE$gW-YDFbS4pM zg%W2{afh==IjrsL6`-RcuNZFeuoLzud0JM({Rq*Tvs;eNv5(UQF}J6(vxf)5*BP(&mriy zrx#Ss&M^CvrA{uenKCYA0js)$fOlsT?UI$1{Q|b%g1vu&Ce^sW z%7+AzNymAfn2l>GE ztzO^r+@gP2k&IvBAQ%-(DzzK5A=WgoK$fdo-O8FA%Woic+BXy?@qRI zQ(P7gypVf`aPn*A!~4FR-PR(;I?%5{(pN+9U)wFezX6i;Px3XMlZY-lr^>{Vo7kaue5tsY-I(Sx zSlDaUWgB~Y18Sz1{6h9l=7Tf!ZDcH6d*d*tf1HSnT4M+j9^;KE_6Z1?UIwv#M zA=<8*cwsW~-sNs05hv+V8~r3Wb)l2@?3kUwwJT0V%Msi*W6Ewrwt^%*jT-8%0(0ERgf%l zq7O7IK7Hu9A#zgjjf4-?##?TT+Ad|MKKr6oRL(%OQov%JC?-*R=-@v%8#y@A=ILv!Q|hB)Z8r6KIwPlGVs^>Uwrue4 z!-vt|moWko{R4gEkMQn+J^Nie!AQFe1vmv6sn8|;lsB0L^8(9TLr6DScT_3?VVe_Z zoC=CZx#YB2saW}}PP|n^&4&dt^K3Co;>&v_jT8kx`{X9>Ne_Ya;&2GJbi`L{O^fs$ zT;D3_M{cro8kbPxygemtnt0;77XW@Bpf~!`;3j-s!Tf>MgEs@-f8(> z5~&3qj0VTm&O}@IhnV_f3B_#>=RS5^w;2y)Q~-U;qea_G`l97lBLSD0tb)*#Y6s$f zJGn2rKF{6WWBd!!4E!ixgUx?pX?NKZv)yOZM*jXYNg}KAPI#v&Q8W^C%_>c;0FdSZ zY5`KjX;$Mx{YnZ;@efL^-ihMDF&i$ZI4d(D^NLg4I7~JMa-+V-J)_oOjVA!XgBHhW z&N9S~eml8bDtlnh6>CYwzw)wk-Kg4`aKOEFzQjvF zOjuMmt~Nnw3TYz;`zgumc$rYB5-uW~Zy2BW0splHf;p>^XXHP7JPgy+_#~}yIPP3U zx3ibTUTT}Y@)AjLCvza8?yU6;{s9xWSk2CMEttF0lxMG7&KYgLXh7(&j*l{lm?Hd~ z__1>L0a)xjM3)MCAYpXmjgpL#B>*+`0hPUsEmAD|fcCW(sJhGjqVKUMm^h746eh?# zyQF{z!|fZ1tOytWY9U z_Q^X~hFf!;ijWMK$fgTBPh~gwJ&a`Hvy_{AB3BM(XE`yuR8+9!buEwl=A8%)0&|Q>xE}gCXU%?tSlfC!6qDl4DRgg8Dv*7kR&9HDdTfQ>u> zaVvOMJ^$-re4s`aqL)6wVe?1f!fLP~p)w4o8btZ15u^SpZ`to!EqNEkL$Wp zuHWP;TawoNFa|TcIBB-s?^BwIo%8siAEMF9X#bU~Ei0{Mx7Wry&z9bjBG>6fM=S(@ zY^{N15Z}_dTP$T+bh4(e=xJqN;ksCWBSkcuhHwSs4!K`ssGx_QeW$MSvE;n++JdNvQRu;R)od<`XhFT>`+3V+m(u5k6UB5-o}6vi|D zi&0hIf#Z4n{ClI8+J+*7t#t=kYlBzrB}1%)lg%V{x|K>?vYZxmaf3sXVY;E`4?BO)Y@c9if>F4b9)48FfBb?-+&(_6ahr6mqA$P2GfgQM{tM@*RxU$+ zjLT~|F+O+DT#5cMuY5c%$Mix;9I&5(lNnw8=cbqt4#3-|wd-lM$$sI3`H)Dq<_;)G z16AdyC8R*N{B9JCFl&vD)bXBDVxI|}ne9($$bRQke+_AI0DaJsw1Kh)q&N zt{bJ%7?F;0_BQv#bp7}^M$~z@if|=Ns&kEVlmYNc*8rO-t*T*Bs7l_&>`rg_ zJqsf-pL~d|FSt_ZDnt@X-5Z?EDaQ8fb|QYZ46f2mI9j)0GTduTP(h8pN))N^7?4T; zTAxqq1Njwq?xBkQpoJpYbJH6bLj2n^ZH^h{;W9XY4|8g2l5qL6^ffl&-Dck?Ws{kw z%ySd-uvd_qe6_ya$BwVZ*TRnfgkrAF)NXIiwB?ajdM{q%YaOTx%stO9zdTi^bqJMvx zOxg0Sk_h)!PQ2@*Z2T=HQ*$=Ryy2u+SMT`x*56(M8oOPxHC`lKxwJb!nuL~_gg=~f zGW{vObFP_q0N0G2Pp7syqas`(4=dfDMd4I?DnmbSO z7CxU1byh)g6l)&)GnIvrMkS#tMHgO=TFa4k+%IijP@!Nn z*0`!W&?OeP*Y4hG={y?7YHoQ62nY&Qj|&@MC5NJj*XuZ4F%e$x8Ic^eq)b<{YqJk{ zaodPX$gd4b&oWGsbtFWpE>NdC#iTmQaqw491BF<(vkv8~%WnBS4om36`X6@^E)1RH zUSetK_(i?i{^Vf1p~q#LcbZSQ=SZetjqZGU-1%U`QERwcSx=EIop+B`b=tsA zn7rNa<4-4Q9LDm{D(gY4u1c`33kR`)9EpUG*_9Jzgue*S=NEcw9lPRSwC06+76{vZ z$uipWLGsaqRVqIrTnA;xJQzLR&_IHw46sjm~a#ErDlPk9mD)B5t+%idpq ziMnT{`9zca3FN$s0MIQye}RatdLCl)Tdr^#C$7ZTB9dE zK_#l)EGNh*C(^~SFnk9S+x8F((>O973PROG8~iC&HBFWULuF(u14N^L(a?0{R$qHz z*oJ~E)_-z&(m45b(=E|ePyO@kUpi`Ev-SCKw2#0w5UdB_CJzCYaBhfO#l2-x`A;fT z>701Mm@^XR2s$@U!s&pw2k)4o!%si*?__a{=n)#}`=NrL=1XRRnC?uv!xOH5+ zI#g%fvuqyxc_gi=KVjcEHkuDpbY@|UIxbbw7lvb3t>2%9yC%0Ur@zZ(Qy1p*I=dL45h)|$Eau+idsR|ixpn#omEG3wpO%F&p zF;HGf9KYhQ!Tl8XApIXFl?Jib;+5OqAELMVx&(+T2C(=fsLaU1`cHP$8a9K<@&pf{ z<7fy1mmq3593Yvk?RTaI?<1KM0v#ZCL=uQ|OJt}1o>mr^^&Zk*>hhWnP+4cF;|%b! zi8rNh>wd6zp|8`03M;BE$ssQg6Bj$*(=Z|02gIFm-l#7?_|X#Z2ckeCMA?W0!6MpK zztMn+#9tCJsqfphM#Xz`(b21QP#vyZ2W?eyhUG>_yWm(b)toCA6kI4Y88EQ?P7UK2 z$m`*w?fTE307Oo0SbdFF#?jpRf7G9{d|ZG3$xDwq#r?<*Z4j_^1JH1>y(s*~XH%g~ z+M6N>vR_v;#9r*8cj-UQ8nTi>=7Pq#Ue;C=i?|ey^5Q2kHMi}&oJXpfqu(+q@ED(rMruTDGv+c{ zCm-64>nRC73;CueSCrC-y;t%Upe|Wx{vqQd0HfN2{%2ok;`T1tBRbJN|66^%7KwXG z6Xw(!NN*e!WmGR!{OI`?5LBtl$FR+n*F}nj<_%NLwPCSz;p3j-h)Sl}%C80Nv-;({ zqUpaKzdy{be*D$&vo(|OP)h0!48UQ(1hV0d*x3P9dL}^yd`<)*Zz{F@?gXZ+EGES0 z0fEj&A%ewssn#*CHX&gQ%)%5MJn5$ug`Y-PiYxRrC$GCo&IwlR(qPfsI&4Z1e40B9 zWvy_y4TkAAfEsol&klPpj!33QzQ$?vbs*)gNFp7|3ZY`$NK_Tc`77U5w&|X57`ut$ zN623cQi{xa*bOqGdvLjv6ySeFtEwlS_17gJPcX1#oykBe+h3~81C_x=<9up4`tZ9`0Jb7T$%tFgJL?#9qoKLxn|7cwduW%Z$CxSN6L)w7 zmHKvh^}@h|CJGXDXQ9L28C8^9R_C+1{a2cfj(S~8ubRjNI`*laOs}@sPCV*bV_44{N|&(b+z=^*{Qx*HSo!;zOKQ5iM~v}_V~vRjr^*d`I8;OCivOfWp~WtodyS^3DwQV z=GM#7jM7&Qxw8QnSf_+Bf!D5#9Jrq|AY+~)R z4L57j4}KrN3k}Z;ZP=88N_*y8VkVHA!>!E5`4yP&3@`b=Xx{HhKYp8elg;=1u8oM~ z|Kcz4#9L`>KbFgz#__zEWhppb5C8y)X8=!71^Wq9&WeAA15{@@+#Hr@eeOT;4(Y}? z2(&G5Zr(eYdVILP*m6>LNGnxQn)C)mqfC=5Y1;0WIF{xl<(yzOgKc^q zikKcJ*>7kOPA~-;dwI^R8vv2T#T#VzC{~~%_3*`=>^|FJPrReS*@mN?VdV-yj&~zs z@7a+wyZcS$IZ_|xm10};N@FyJ=MIwaimp|Tl;3x=R7sxAWL|#HPo+Tf$Ji@V_mcpE zei#J2eF=ntLC`OiGI!KU9w_}%=}TwtS3%wC>k6$w+aq1$PUIXzQ|_1ex)dVx!d?6E z=~61k0fYM9^4Mw1iZws32nWfMn`jCvy#tBw?neCbtbTWel3}dPtjaJlBbVhRI+_dB zd%$kQewQ}he1HGdZVV79eF@~1)zhg>_rbNIF@xa!a?rC}hdmG+qiAPkMIpADmH5b~ z`{?^vHw?t-&9~m(Tw|{{Dqg4LQ(57u=Ik*0XTNUjsfkvB`;k88*?M-?&phXiGmd=- z0Ll#0?;~2erT-XB1ii)ExOH%rqhAtTNte3*XE1mfMxPM#f<4yMxa#LJtPO zT9yEuL5nE(aI&2qP8DJD8kY?X4DwdU_e)TIxaiQwT-!V0YujzTK2pvvpw;F_H^jU4 z>3Rhz_N5N*cSPmK#kFM%xur+9&t@7mv_HYdw;OZj*>G z8pjr_y&oph$V{n3pm5>c`{mzVACl$C_u9z3iP)T2rcJC}kL0J->jU)4ObQIX5|;e5 zrj=0mA#~lOIHS3pcOo*YRE5~6KO+-aJ@U}mToF}412}wZ06Dt@S6E5uXKgYEnJR8% zGAmR0H!H3O_Kf9V($Yk0x-F;o(&N5u*R{eFG|K8Z9`3H9aT;HA*x8I-IfgN@RPM(V z8X3>B#eATs-|*z>Ru{1z>v0-eC=ixKC8q{qQQ7#3QGrbzWbLh~?c-ZxtCz2$42M(j zNF8Gli{Fc7O&MK+O`O9f z3J9Z3hn;Ztm*V4A+-LWEorY^6&`&9K;1ZR0Aajnb-P z=hw_0>Gxb}ey7pe6AnR0PP_m)n8LMFC`)?SFSXg5yBpDYNH1asI2G*Esz1 zb!eCD;qJJ#E=_q0wqIvtSQPlT%eH<*U)o{y!#tO##9b!OY+s3~EoRP$jyI|Nk+fD( zOc7Q0rzhLk7BSz?M3qFb^WMB`Kn6kARCL9MGxkuRaB+MxIOioOh zioeNFKiP3?|C!Q+$4*aF+09sEVHBdTVjo${EJD&4W6;dKFtQc8F;Pt1KB|zOcX7AN z5QExdb;2%C&n-aFF_EP(;@+)ab`dg`yS`Ct2DkK|qSZ^fByRIn=PWG!>?lU1X_Ag4 zQ17f-HaD#8Vs-Vv3j})M{@bOY`0aZW^9!l8N5my1-Rc_34aytd%S~NET=f^U3e+xG zb-dtM15E4B`3?KwHnv&6Nz%UNa^=>NmV1j~>@oBqnM%~T3QqTrDFUs7XRZ2W^oIYP zU1kqoiD$IZrnQ~yDEysgk8u;Hd;IFa{BIz?)Q$|j0XAU$P9OhGKCK@!>L~|<>XdR* zfC5F>n(ykg-_m~wsK<0kV*4{(mRSFs__~CVY?)>qLnXH}jK21`i3|PnTY=dcTGZ#c z&G>71@#0IsiJDd9}G>h+bx|G+q-I-e?dpl33OC>xuucrIx5PHLX02o^k5Yzgn zB}j85Lk&e>!UK5Y3>!{U+87P13fb6qFyd>Aj5Khq{bM59tz(#R;Gz#N*ElBHc=Bm} z6_3-rv!<9tnuwCV?Xjza=qX+o_2HKZXh;eh>Ms`BX#BpK;nQe!Q_K%nN^Y5Ae`u0RC$BlvPo*q=pXGJkS z)aBjOFQFygRMAx7VGxGq_e7}Q)jG0XW?pRxJ{sY*|O8=qGkGG)YzpK!x)_wJj%&F@M~=u6h`{E zDSpBH>P=nfj+Ws$<6&IM%YKgW&Uz4BGYTKBZ8-8NvIeX|Yxl?;YLk;ZA(U7mzRigv z%Nu}EJ8Tim@>~tju)<4}WW3ea4O*?DRPr|84$X4#N^B^7qtRyhO)C9PYCNT^gCe|+ zn<$Al(|34ucw&4kv8qm_Y*Hsf2`jO0Yk;T?3=N%{NBa>e+!u3d)|?h&^V2;Wt)5KB zRj2Q-Zf6HX+^q}rmd3bI5gK%^7A7B`YpJ3>iWY@QxahSo$4LZ4vZ)+>4S0hL&3W1~ z`D;-^Xu?X(W;&G^?yT`X*Jhfv|BDrS8lOUscY9P0(6fCCFY>fO+CG=+s`33YL2*m} z1zKI^t;Fs7ivx|L2-eQ@0~eg!)i@JGF%{h`RGjbmq0|1&Ogny@Eq!Qv z?$%i}0pxfMsD;aAfqeLrDQTSOi8xzhevs-Z!C;g#g4zVUJwY7m* zM<0Hbc)Ips&L)$tOr(W4Ta-wpU3***_4&33g*=M;>zExJ;E0L{_W}WUZg@qM&=NqN z0|!!+L)qLS{7B)AhmJiU+zDEyf7;tk3lqThGqL^(P_HP*;*G1)kj4=k-$jITeZ1F* z`yrYThfF@Sx#BJBre~%k{gyh#$EtgRFi**Z>l~y#^q1Eg^|K zxn9oqOw&?`NKEM&H}ZvlN}5|%>*X3MW}-|(C(RF*cb4#kL-wCE*gbH4<>F^n>YvYN z-s(h|tre_( z+;sU`@nqu8Tl&9))HS-qu=shcPE#(Dh(J+)H|=q}ju(QEaVF+ILrkH5U(xEeIsr6d&nr_bg=QVq#zA=CUw|ftT zud@OqeSG4c&Ha2td_-hg1BCam`xN@v`F6F#YB6j+Uuj0YjrCXV+O!T&aXz3fL!O}= z`M26$7&MGc!tbdIeV3@sM$V9K-FM}L>XgjCZ3J7ZQ&;>TnjqwkdNC7f5Qmav z%VR_dXAD7%;gmHbJsbn~HfE5?cjq8RAw4XM?)9uPx>o zjvKQfS=rzB6TPcL&8y>Ca=7I&;dHLW$*R(dxLOX**-f}R6NexEMOXW!qhW?W7?J~< z-A;y!#%aW>C_PO$wKVnv`4~o>WrhR~K3-IQF#nis9~o~elc@fD*j=9G{J;A5H=yp+ zC31K;TKV`EZwPn&$Djuvl{H;!)49NGVY&^d_`Dn%@~zJWzkf<}1xDH<0fgucKn&;y z;u5KqhJgt9HU;>f5(mxQkY1Kc#MLo9U@Qq)qh8Do$dXL*18?Sj{5PDp2rCMtmUfs0JnUVY}+lItVG0!L34~e-Y3>0Da;BRgKMmU;UKiF*|29KrmAWj^0 zkKLleT?U*VPM|TIKTl19gD=@Q_A9`$R$M%ureW2E-)u{k@HiNos-!(t8Z|al6$+E3 z*X9+(f1Xm>P82;QIgq`a2#{(>cDqRx2@KMCj3sC1#d3Y)!q>3)a4 zGF%^hrb2b+mi`}TwR)_*WBU&}S!L(g`a;q%fNL!rGU+2#U0dy;_ho9$Lf9_9EO(t= zjSBs|K{zG4I{V9b`P0eKk5jsDjg4i(FZzJ`^?ivFQ!cAm0@A$FQRRph?;KbJ?i}ix z<`)e~g|B!5Z9w4W>X@!`k_HJgou-}AuO}{DtrA3nEV1)B#y+Vsvx)vyW;6F-7H@p! zjeTtZluX$TfDg6e%4A1lA|kjdz=ZAmDOQh~$Kp7HYXZk)C9U#evY6nuHw!e~p_}ljQ zXa9t!oOSabn=@5jmPa}D3JPU8DZyawomcF#LT`p)*&d3ys4ctiMeXOkfSxggG^D zB;?~w=<=hVkQ=%7b`dShYenc3jO6J2>RQeg<|>K(x_8nx{mnqdUKoah5V-;FHn`wx z2O{_KaZ_y%6DdE<*KE?MSmVRP^hHbS8U7gBK7J|OU=FaqUinUaYC$>8{b>pzsT%vM za?;$NS^89-(j+zRhkV**{=YGDgKQ`ORX>H$$M~3<`yyjl)mgUcMK}Z5)mC2Oad`Tw zgcbm)*!u3a-$+=PFH&PS$>p269fFN_$m5P0KVfEW>MYyIx**&G3Z*>p~N zu6{pH(u1?Cn3=!lVQrqgOMW1gk-~y{TCiA>=BqjpQQB)#^k&vI;8|JV{qLuRP}zg= zDH*s>_84n8PzmE3=2PRW>t2JzGVNP3yaT3>S~WS7srE8KEJ&_k;Bn^RdIChBceLi>MoLNcoyv{Wb1nZz z69ZN2neV>>{u9(m9_GObi^0piyLr`I$slyvGl0ODm$MUYH6l6(J8LF1WRw*sY@0Oa zYDh}p8zZe)ehlT8b+{@Rr4RELBy#Eh$az@)tcvUd#6B$=sYq_BrfK{Cekmpki>rHk z*xz080|~nikBQ@t)d^CsX6% zN$h86%KumQBxIJFnCj_)edS>2%vl0vU}`VQFo8zd{f&!k8AUG_9r&EjohX$b2Ec_=Azx{OA2H^GqKXI}Pl7JmJlu zQy$XDM;*WvcNSD?Ti`hjtJb3ZUUW3Ajcaz}){^8LEs3;$+762#HJflFF z9Kw6_<3UE}OUMD@cfi-4#H+7PDaVQ~8H>IJZv??2#u@XfqK5Ih&n)tPb7U+V`=XEl ztn~7h{u_V@(j$C`^{WOH93d4s8UT?JD~_ZHpDp2toY0?f1sunur!`gfn}Rt5d;_V` zT3!=7$l>GO>RDu1YxQ!*q_c}1ls5ZXLW|Bo|xn)&dYbain%4 zAE_ZC8?~Ql5H6o8R4MY-!r=7t({HfXPi_DzeMC6R6)rH$^(qCPRjg85dUISia|#Y7 z_r4on!&*T2U1h^nH5FtP5PZMVpu(Bb?W6Ow5R0m*bAh!E{P9nhq(5sX|HC<9M2g5^ z_lLdv6b{(_jEK;Sjhi7SdwDp3JiY{AIkw@13ElhTHM=ULRI>5@MEos;VcemarA^8oejYtpnQsh(JbVg( z6^XtvJD}I-CfM%P`yQ~rYQf#FkIX0ABNjHz_~lmR)&2C<>gPkwP=U;I0-DLeY0fj|OaB!>$eWAB`M9+rMOh1mo{}Ge>!uzd3s!gd0P>g}VrIfqNFKP$mU+K4 zywF^5!DAwYp5W7{pV>k%QD(Hyl0Rod^j#gPvjFo~s3|vFx$s*hb1pU_nXwS8bUeWCtk%Ya=yf;yb) z^tIcDU3V-2i1g)^fvdlPHlTx*C`khm@iX4i2eL}M$w(X}g$=8F|L@OHS;B`sqX|Z153-RW=eyzv!+1xm! zF52kX!c9KHBBwy~1HuLmWb%_Slynm14slYGqkAMps(_iwtm0Uuf+Z&PQ#UJ1%VXp9?$Dh&{6^o)BqmkjVSaNZq zLgb_P<8rd{x+9DaS{j`G=TB7I6NL{4XRL+DusF|Na^COPxA_?4 zYOyS$E9>-}nU-Qu(HeDj`i7)<>Y)qBKxi5KjY!XDarTO;+Q?{kC zadT9oKj)H-ZT(f%smjmmA;I{8WR(19!6R?z6Az7Lu;;WHAvV<; z^K87sJWC1-nltp^4=Qq%y|;4y2dI8~DSX)NvCf^wA&t%%q;zhJ+t{RZpI@|Kc?2TI z7)v1ieK{!U28iavbAhv1v*?mopV2IjazhRtTkIuP0i|!JS=01L% zowkT>Hc^os-lknZl+(Z#smBU_t07|(kG82ZE(oNf_oyl;FUL4SAC5#k=mQ8qLVEZB zp+a)I?p;yzT7fU4ljm4#wdZ>krbOd-E3#WUuhD93SUg0`Bamce(g`Jh_&bl|WvIu$ zhuCE~2_D7+Kzo%!B-^Ua;P`6F{CXU*Eb{aa

5bCNWoTx2 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 literal 36780 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 From ce5f19321961593d99f612ee0fccf42a6e036362 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 1 Oct 2024 14:42:31 +0200 Subject: [PATCH 090/134] Fix Z-Wave rediscovery (#127213) --- homeassistant/components/zwave_js/__init__.py | 5 +- homeassistant/components/zwave_js/const.py | 1 + .../components/zwave_js/discovery.py | 3 + homeassistant/components/zwave_js/entity.py | 3 +- .../zwave_js/triggers/value_updated.py | 3 +- tests/components/zwave_js/conftest.py | 26 +- .../siren_neo_coolcam_nas-ab01z_state.json | 746 ++++++++++++++++++ tests/components/zwave_js/test_discovery.py | 46 ++ 8 files changed, 825 insertions(+), 8 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/siren_neo_coolcam_nas-ab01z_state.json diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 4844f707201..06b8214d941 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -100,6 +100,7 @@ from .const import ( DATA_CLIENT, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, + EVENT_VALUE_UPDATED, LIB_LOGGER, LOGGER, LR_ADDON_VERSION, @@ -623,7 +624,7 @@ class NodeEvents: ) # add listeners to handle new values that get added later - for event in ("value added", "value updated", "metadata updated"): + for event in ("value added", EVENT_VALUE_UPDATED, "metadata updated"): self.config_entry.async_on_unload( node.on( event, @@ -722,7 +723,7 @@ class NodeEvents: # add listener for value updated events self.config_entry.async_on_unload( disc_info.node.on( - "value updated", + EVENT_VALUE_UPDATED, lambda event: self.async_on_value_updated_fire_event( value_updates_disc_info, event["value"] ), diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index a04f9247548..fd81cd7e7de 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -42,6 +42,7 @@ DATA_CLIENT = "client" DATA_OLD_SERVER_LOG_LEVEL = "old_server_log_level" EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" +EVENT_VALUE_UPDATED = "value updated" LOGGER = logging.getLogger(__package__) LIB_LOGGER = logging.getLogger("zwave_js_server") diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index bd2b3a4b3ce..63f91d5b83d 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -1363,6 +1363,9 @@ def async_discover_single_value( if not schema.allow_multi: discovered_value_ids[device.id].add(value.value_id) + # prevent re-discovery of the (primary) value after all schemas have been checked + discovered_value_ids[device.id].add(value.value_id) + if value.command_class == CommandClass.CONFIGURATION: yield from async_discover_single_configuration_value( cast(ConfigurationValue, value) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index d41c8bb01d0..d1ab9009308 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -22,11 +22,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import UNDEFINED -from .const import DOMAIN, LOGGER +from .const import DOMAIN, EVENT_VALUE_UPDATED, LOGGER from .discovery import ZwaveDiscoveryInfo from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id -EVENT_VALUE_UPDATED = "value updated" EVENT_VALUE_REMOVED = "value removed" EVENT_DEAD = "dead" EVENT_ALIVE = "alive" diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index d8c5702ce5d..d6378ea27d5 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -32,6 +32,7 @@ from ..const import ( ATTR_PROPERTY_KEY_NAME, ATTR_PROPERTY_NAME, DOMAIN, + EVENT_VALUE_UPDATED, ) from ..helpers import async_get_nodes_from_targets, get_device_id from .trigger_helpers import async_bypass_dynamic_config_validation @@ -184,7 +185,7 @@ async def async_attach_trigger( # We need to store the current value and device for the callback unsubs.append( node.on( - "value updated", + EVENT_VALUE_UPDATED, functools.partial(async_on_value_updated, value, device), ) ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index e90c1533b5f..0a8e445a3e6 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -3,13 +3,14 @@ import asyncio import copy import io -from typing import Any -from unittest.mock import DEFAULT, AsyncMock, patch +from typing import Any, cast +from unittest.mock import DEFAULT, AsyncMock, MagicMock, patch import pytest from zwave_js_server.event import Event from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node +from zwave_js_server.model.node.data_model import NodeDataType from zwave_js_server.version import VersionInfo from homeassistant.components.zwave_js.const import DOMAIN @@ -488,6 +489,15 @@ def window_covering_outbound_bottom_state_fixture() -> dict[str, Any]: return load_json_object_fixture("window_covering_outbound_bottom.json", DOMAIN) +@pytest.fixture(name="siren_neo_coolcam_state") +def siren_neo_coolcam_state_state_fixture() -> NodeDataType: + """Load node with siren_neo_coolcam_state fixture data.""" + return cast( + NodeDataType, + load_json_object_fixture("siren_neo_coolcam_nas-ab01z_state.json", DOMAIN), + ) + + # model fixtures @@ -798,7 +808,7 @@ def nortek_thermostat_removed_event_fixture(client) -> Node: @pytest.fixture(name="integration") -async def integration_fixture(hass: HomeAssistant, client) -> Node: +async def integration_fixture(hass: HomeAssistant, client) -> MockConfigEntry: """Set up the zwave_js integration.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -1192,3 +1202,13 @@ def window_covering_outbound_bottom_fixture( node = Node(client, copy.deepcopy(window_covering_outbound_bottom_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="siren_neo_coolcam") +def siren_neo_coolcam_fixture( + client: MagicMock, siren_neo_coolcam_state: NodeDataType +) -> Node: + """Load node for neo coolcam siren.""" + node = Node(client, siren_neo_coolcam_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/siren_neo_coolcam_nas-ab01z_state.json b/tests/components/zwave_js/fixtures/siren_neo_coolcam_nas-ab01z_state.json new file mode 100644 index 00000000000..41fc9e37423 --- /dev/null +++ b/tests/components/zwave_js/fixtures/siren_neo_coolcam_nas-ab01z_state.json @@ -0,0 +1,746 @@ +{ + "nodeId": 36, + "index": 0, + "installerIcon": 3840, + "userIcon": 3840, + "status": 4, + "ready": true, + "isListening": false, + "isRouting": true, + "manufacturerId": 600, + "productId": 4232, + "productType": 3, + "firmwareVersion": "2.94", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x0258/nas-ab01z.json", + "isEmbedded": true, + "manufacturer": "Shenzhen Neo Electronics Co., Ltd.", + "manufacturerId": 600, + "label": "NAS-AB01Z", + "description": "Siren Alarm", + "devices": [ + { + "productType": 3, + "productId": 136 + }, + { + "productType": 3, + "productId": 4232 + }, + { + "productType": 3, + "productId": 8328 + }, + { + "productType": 3, + "productId": 24712 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + } + }, + "label": "NAS-AB01Z", + "interviewAttempts": 0, + "isFrequentListening": "1000ms", + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 7, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 5, + "label": "Siren" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0258:0x0003:0x1088:2.94", + "statistics": { + "commandsTX": 15, + "commandsRX": 7, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 582.5, + "lastSeen": "2024-10-01T10:22:24.457Z", + "lwr": { + "repeaters": [], + "protocolDataRate": 2 + } + }, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-09-30T15:07:11.320Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Alarm Volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Volume", + "default": 2, + "min": 1, + "max": 3, + "states": { + "1": "Low", + "2": "Middle", + "3": "High" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Alarm Duration", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Duration", + "default": 2, + "min": 0, + "max": 255, + "states": { + "0": "Off", + "1": "30 seconds", + "2": "1 minute", + "3": "5 minutes", + "255": "Always on" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Doorbell Duration", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Doorbell Duration", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Off", + "255": "Always" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 16 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Doorbell Volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Doorbell Volume", + "default": 2, + "min": 1, + "max": 3, + "states": { + "1": "Low", + "2": "Middle", + "3": "High" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Alarm Sound Selection", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Sound Selection", + "default": 10, + "min": 1, + "max": 10, + "states": { + "1": "Doorbell", + "2": "F\u00fcr Elise", + "3": "Westminster Chimes", + "4": "Ding Dong", + "5": "William Tell", + "6": "Rondo Alla Turca", + "7": "Police Siren", + "8": "Evacuation", + "9": "Beep Beep", + "10": "Beep" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Doorbell Sound Selection", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Doorbell Sound Selection", + "default": 9, + "min": 1, + "max": 10, + "states": { + "1": "Doorbell", + "2": "F\u00fcr Elise", + "3": "Westminster Chimes", + "4": "Ding Dong", + "5": "William Tell", + "6": "Rondo Alla Turca", + "7": "Police Siren", + "8": "Evacuation", + "9": "Beep Beep", + "10": "Beep" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Default Siren Sound", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default Siren Sound", + "default": 1, + "min": 1, + "max": 2, + "states": { + "1": "Alarm Sound", + "2": "Doorbell Sound" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Alarm LED", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm LED", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Doorbell LED", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Doorbell LED", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Siren", + "propertyKey": "Siren status", + "propertyName": "Siren", + "propertyKeyName": "Siren status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Siren status", + "ccSpecific": { + "notificationType": 14 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Siren active" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 600 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 4232 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 89 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "4.38" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["2.94"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 48 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + } + ], + "endpoints": [ + { + "nodeId": 36, + "index": 0, + "installerIcon": 3840, + "userIcon": 3840, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 5, + "label": "Siren" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": false + }, + { + "id": 135, + "name": "Indicator", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 57841ef2a83..efcd551d70a 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -1,6 +1,8 @@ """Test entity discovery for device-specific schemas for the Z-Wave JS integration.""" import pytest +from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS @@ -28,6 +30,8 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, Entity from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from tests.common import MockConfigEntry + async def test_aeon_smart_switch_6_state( hass: HomeAssistant, client, aeon_smart_switch_6, integration @@ -380,3 +384,45 @@ async def test_light_device_class_is_null( node = light_device_class_is_null assert node.device_class is None assert hass.states.get("light.bar_display_cases") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rediscovery( + hass: HomeAssistant, + siren_neo_coolcam: Node, + integration: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we don't rediscover known values.""" + node = siren_neo_coolcam + entity_id = "select.siren_alarm_doorbell_sound_selection" + state = hass.states.get(entity_id) + + assert state + assert state.state == "Beep" + + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 36, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 6, + "newValue": 9, + "prevValue": 10, + "propertyName": "Doorbell Sound Selection", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == "Beep Beep" + assert "Platform zwave_js does not generate unique IDs" not in caplog.text From 9d059fcfaaa5f05e0fbb996e367dfda41af3939a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Oct 2024 14:25:32 +0200 Subject: [PATCH 091/134] Use reconfigure_confirm in vallox config flow (#127214) --- .../components/vallox/config_flow.py | 25 +++++++++++++------ homeassistant/components/vallox/strings.json | 2 +- tests/components/vallox/conftest.py | 2 +- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py index 3660c641b7c..a413a641d18 100644 --- a/homeassistant/components/vallox/config_flow.py +++ b/homeassistant/components/vallox/config_flow.py @@ -2,13 +2,14 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from vallox_websocket_api import Vallox, ValloxApiException import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -40,6 +41,8 @@ class ValloxConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _context_entry: ConfigEntry + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -83,23 +86,29 @@ class ValloxConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_reconfigure( - self, user_input: dict[str, Any] | None = None + self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reconfiguration of the Vallox device host address.""" entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert entry + self._context_entry = entry + return await self.async_step_reconfigure_confirm() + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the Vallox device host address.""" if not user_input: return self.async_show_form( - step_id="reconfigure", + step_id="reconfigure_confirm", data_schema=self.add_suggested_values_to_schema( - CONFIG_SCHEMA, {CONF_HOST: entry.data.get(CONF_HOST)} + CONFIG_SCHEMA, {CONF_HOST: self._context_entry.data.get(CONF_HOST)} ), ) updated_host = user_input[CONF_HOST] - if entry.data.get(CONF_HOST) != updated_host: + if self._context_entry.data.get(CONF_HOST) != updated_host: self._async_abort_entries_match({CONF_HOST: updated_host}) errors: dict[str, str] = {} @@ -115,13 +124,13 @@ class ValloxConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_HOST] = "unknown" else: return self.async_update_reload_and_abort( - entry, - data={**entry.data, CONF_HOST: updated_host}, + self._context_entry, + data={**self._context_entry.data, CONF_HOST: updated_host}, reason="reconfigure_successful", ) return self.async_show_form( - step_id="reconfigure", + step_id="reconfigure_confirm", data_schema=self.add_suggested_values_to_schema( CONFIG_SCHEMA, {CONF_HOST: updated_host} ), diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index 8a30ed4ad01..608a5eb1782 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -9,7 +9,7 @@ "host": "Hostname or IP address of your Vallox device." } }, - "reconfigure": { + "reconfigure_confirm": { "data": { "host": "[%key:common::config_flow::data::host%]" }, diff --git a/tests/components/vallox/conftest.py b/tests/components/vallox/conftest.py index a6ea95944b3..114728599e6 100644 --- a/tests/components/vallox/conftest.py +++ b/tests/components/vallox/conftest.py @@ -88,7 +88,7 @@ async def init_reconfigure_flow( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" # original entry assert mock_entry.data["host"] == "192.168.100.50" From c8b92bc85827b4447f46701ecbaaf67444d43ae4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Oct 2024 14:24:44 +0200 Subject: [PATCH 092/134] Use reconfigure_confirm in solarlog config flow (#127215) * Use reconfigure_confirm in solarlog config flow * Fix test --- .../components/solarlog/config_flow.py | 29 ++++++++++++------- .../components/solarlog/strings.json | 2 +- tests/components/solarlog/test_config_flow.py | 2 +- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py index f161fca0297..6c170ed809e 100644 --- a/homeassistant/components/solarlog/config_flow.py +++ b/homeassistant/components/solarlog/config_flow.py @@ -138,40 +138,47 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_reconfigure( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a reconfiguration flow initialized by the user.""" - - entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) - if TYPE_CHECKING: - assert entry is not None + assert self._entry is not None if user_input is not None: if not user_input[CONF_HAS_PWD] or user_input.get(CONF_PASSWORD, "") == "": user_input[CONF_PASSWORD] = "" user_input[CONF_HAS_PWD] = False return self.async_update_reload_and_abort( - entry, + self._entry, reason="reconfigure_successful", - data={**entry.data, **user_input}, + data={**self._entry.data, **user_input}, ) if await self._test_extended_data( - entry.data[CONF_HOST], user_input.get(CONF_PASSWORD, "") + self._entry.data[CONF_HOST], user_input.get(CONF_PASSWORD, "") ): # if password has been provided, only save if extended data is available return self.async_update_reload_and_abort( - entry, + self._entry, reason="reconfigure_successful", - data={**entry.data, **user_input}, + data={**self._entry.data, **user_input}, ) return self.async_show_form( - step_id="reconfigure", + step_id="reconfigure_confirm", data_schema=vol.Schema( { - vol.Optional(CONF_HAS_PWD, default=entry.data[CONF_HAS_PWD]): bool, + vol.Optional( + CONF_HAS_PWD, default=self._entry.data[CONF_HAS_PWD] + ): bool, vol.Optional(CONF_PASSWORD): str, } ), diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index 7dc7dbb84bb..69ebbbcceda 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -29,7 +29,7 @@ "password": "[%key:common::config_flow::data::password%]" } }, - "reconfigure": { + "reconfigure_confirm": { "title": "Configure SolarLog", "data": { "has_password": "[%key:component::solarlog::config::step::user::data::has_password%]" diff --git a/tests/components/solarlog/test_config_flow.py b/tests/components/solarlog/test_config_flow.py index 17c32d8b38d..ff7cc2209b4 100644 --- a/tests/components/solarlog/test_config_flow.py +++ b/tests/components/solarlog/test_config_flow.py @@ -213,7 +213,7 @@ async def test_reconfigure_flow( }, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" # test with all data provided result = await hass.config_entries.flow.async_configure( From 5c42e45048ed5ec3497fd7412ed8db637786e6f7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Oct 2024 14:25:14 +0200 Subject: [PATCH 093/134] Fix reconfigure_confirm logic in madvr config flow (#127216) --- homeassistant/components/madvr/config_flow.py | 7 ++++--- homeassistant/components/madvr/strings.json | 2 +- tests/components/madvr/test_config_flow.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/madvr/config_flow.py b/homeassistant/components/madvr/config_flow.py index 1ca1dd296d8..1c817c68977 100644 --- a/homeassistant/components/madvr/config_flow.py +++ b/homeassistant/components/madvr/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the integration.""" import asyncio +from collections.abc import Mapping import logging from typing import Any @@ -41,17 +42,17 @@ class MadVRConfigFlow(ConfigFlow, domain=DOMAIN): return await self._handle_config_step(user_input) async def async_step_reconfigure( - self, user_input: dict[str, Any] | None = None + self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reconfiguration of the device.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) - return await self.async_step_reconfigure_confirm(user_input) + return await self.async_step_reconfigure_confirm() async def async_step_reconfigure_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a reconfiguration flow initialized by the user.""" - return await self._handle_config_step(user_input, step_id="reconfigure") + return await self._handle_config_step(user_input, step_id="reconfigure_confirm") async def _handle_config_step( self, user_input: dict[str, Any] | None = None, step_id: str = "user" diff --git a/homeassistant/components/madvr/strings.json b/homeassistant/components/madvr/strings.json index 06851efa2c8..9c7594c68d0 100644 --- a/homeassistant/components/madvr/strings.json +++ b/homeassistant/components/madvr/strings.json @@ -13,7 +13,7 @@ "port": "The port your madVR Envy is listening on. In 99% of cases, leave this as the default." } }, - "reconfigure": { + "reconfigure_confirm": { "title": "Reconfigure madVR Envy", "description": "Your device needs to be on in order to reconfigure the integation.", "data": { diff --git a/tests/components/madvr/test_config_flow.py b/tests/components/madvr/test_config_flow.py index 65eba05c802..a2900d4be12 100644 --- a/tests/components/madvr/test_config_flow.py +++ b/tests/components/madvr/test_config_flow.py @@ -141,7 +141,7 @@ async def test_reconfigure_flow( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" assert result["errors"] == {} # define new host @@ -213,7 +213,7 @@ async def test_reconfigure_flow_errors( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" # Test CannotConnect error mock_madvr_client.open_connection.side_effect = TimeoutError From e25a54aef470647dacc5834248ba907ad1f3667b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Oct 2024 14:29:31 +0200 Subject: [PATCH 094/134] Use reconfigure_confirm in lcn config flow (#127217) --- homeassistant/components/lcn/config_flow.py | 37 +++++++++++------ homeassistant/components/lcn/strings.json | 2 +- tests/components/lcn/test_config_flow.py | 45 +++++++++++++-------- 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index a1a98a39db3..d50fc2fd888 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -9,7 +10,7 @@ import pypck import voluptuous as vol from homeassistant import config_entries -from homeassistant.config_entries import ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult from homeassistant.const import ( CONF_BASE, CONF_DEVICES, @@ -113,6 +114,8 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 2 + _context_entry: ConfigEntry + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Import existing configuration from LCN.""" # validate the imported connection parameters @@ -193,31 +196,41 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=data[CONF_HOST], data=data) async def async_step_reconfigure( + self, entry_data: Mapping[str, Any] + ) -> config_entries.ConfigFlowResult: + """Reconfigure LCN configuration.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry + self._context_entry = entry + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: """Reconfigure LCN configuration.""" errors = None - entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) - assert entry - if user_input is not None: - user_input[CONF_HOST] = entry.data[CONF_HOST] + user_input[CONF_HOST] = self._context_entry.data[CONF_HOST] - await self.hass.config_entries.async_unload(entry.entry_id) + await self.hass.config_entries.async_unload(self._context_entry.entry_id) if (error := await validate_connection(user_input)) is not None: errors = {CONF_BASE: error} if errors is None: - data = entry.data.copy() + data = self._context_entry.data.copy() data.update(user_input) - self.hass.config_entries.async_update_entry(entry, data=data) - await self.hass.config_entries.async_setup(entry.entry_id) + self.hass.config_entries.async_update_entry( + self._context_entry, data=data + ) + await self.hass.config_entries.async_setup(self._context_entry.entry_id) return self.async_abort(reason="reconfigure_successful") - await self.hass.config_entries.async_setup(entry.entry_id) + await self.hass.config_entries.async_setup(self._context_entry.entry_id) return self.async_show_form( - step_id="reconfigure", - data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, entry.data), + step_id="reconfigure_confirm", + data_schema=self.add_suggested_values_to_schema( + CONFIG_SCHEMA, self._context_entry.data + ), errors=errors or {}, ) diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 9b5ce8c9cc0..90650c2aed1 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -34,7 +34,7 @@ "acknowledge": "Retry sendig commands if no response is received (increases bus traffic)." } }, - "reconfigure": { + "reconfigure_confirm": { "title": "Reconfigure LCN host", "description": "Reconfigure connection to LCN host.", "data": { diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index a34592a4f87..67c10b250a8 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -204,20 +204,26 @@ async def test_step_reconfigure(hass: HomeAssistant, entry: MockConfigEntry) -> entry.add_to_hass(hass) old_entry_data = entry.data.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=old_entry_data, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + with ( patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"), patch("homeassistant.components.lcn.async_setup", return_value=True), patch("homeassistant.components.lcn.async_setup_entry", return_value=True), ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_RECONFIGURE, - "entry_id": entry.entry_id, - }, - data=CONFIG_DATA.copy(), + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_DATA.copy(), ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" @@ -242,18 +248,25 @@ async def test_step_reconfigure_error( ) -> None: """Test for error in reconfigure step is handled correctly.""" entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + with patch( "homeassistant.components.lcn.PchkConnectionManager.async_connect", side_effect=error, ): - data = {**CONNECTION_DATA, CONF_HOST: "pchk"} - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_RECONFIGURE, - "entry_id": entry.entry_id, - }, - data=data, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_DATA.copy(), ) assert result["type"] == data_entry_flow.FlowResultType.FORM From f2c746122e51e0b77c0a2542d7ef845ea3b7e33b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Oct 2024 14:41:15 +0200 Subject: [PATCH 095/134] Use reconfigure_confirm in google_travel_time config flow (#127220) --- .../google_travel_time/config_flow.py | 20 +++++++++++++------ .../google_travel_time/strings.json | 2 +- .../google_travel_time/test_config_flow.py | 4 +++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 0b493d7eeeb..a9f68179fe7 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -207,6 +208,8 @@ class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _context_entry: ConfigEntry + @staticmethod @callback def async_get_options_flow( @@ -235,28 +238,33 @@ class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_reconfigure( - self, user_input: dict[str, Any] | None = None + self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reconfiguration.""" entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) if TYPE_CHECKING: assert entry + self._context_entry = entry + return await self.async_step_reconfigure_confirm() + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" errors: dict[str, str] | None = None - user_input = user_input or {} - if user_input: + if user_input is not None: errors = await validate_input(self.hass, user_input) if not errors: return self.async_update_reload_and_abort( - entry, + self._context_entry, data=user_input, reason="reconfigure_successful", ) return self.async_show_form( - step_id="reconfigure", + step_id="reconfigure_confirm", data_schema=self.add_suggested_values_to_schema( - RECONFIGURE_SCHEMA, entry.data.copy() + RECONFIGURE_SCHEMA, self._context_entry.data.copy() ), errors=errors, ) diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 765cfc9c4b6..6397336d9ac 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -11,7 +11,7 @@ "destination": "Destination" } }, - "reconfigure": { + "reconfigure_confirm": { "description": "[%key:component::google_travel_time::config::step::user::description%]", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index d16d1c1ffc9..b3e6ea0f1fc 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -204,9 +204,10 @@ async def test_reconfigure(hass: HomeAssistant, mock_config: MockConfigEntry) -> "source": config_entries.SOURCE_RECONFIGURE, "entry_id": mock_config.entry_id, }, + data=mock_config.data, ) assert reconfigure_result["type"] is FlowResultType.FORM - assert reconfigure_result["step_id"] == "reconfigure" + assert reconfigure_result["step_id"] == "reconfigure_confirm" await assert_common_reconfigure_steps(hass, reconfigure_result) @@ -234,6 +235,7 @@ async def test_reconfigure_invalid_config_entry( "source": config_entries.SOURCE_RECONFIGURE, "entry_id": mock_config.entry_id, }, + data=mock_config.data, ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None From 41b3eb9f79569a40b63fe8682423b9262a26ae00 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 1 Oct 2024 14:54:05 +0200 Subject: [PATCH 096/134] Bump version to 2024.10.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 78c5b0d1561..1351d288b7a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index b4d6d03692b..5ec1bf4beda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b6" +version = "2024.10.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 507492947af78a01bd9a1a35d37cfa0790fa6d5e Mon Sep 17 00:00:00 2001 From: Bill Flood Date: Tue, 1 Oct 2024 11:51:12 -0700 Subject: [PATCH 097/134] Fix Tailwind cover exception when door is already in the requested state (#124543) --- homeassistant/components/tailwind/cover.py | 7 +++++- tests/components/tailwind/test_cover.py | 26 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 8fb0f313480..116fb4a9e6c 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any from gotailwind import ( + TailwindDoorAlreadyInStateError, TailwindDoorDisabledError, TailwindDoorLockedOutError, TailwindDoorOperationCommand, @@ -21,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, LOGGER from .entity import TailwindDoorEntity from .typing import TailwindConfigEntry @@ -77,6 +78,8 @@ class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): translation_domain=DOMAIN, translation_key="door_locked_out", ) from exc + except TailwindDoorAlreadyInStateError: + LOGGER.debug("Already in the requested state: %s", self.entity_id) except TailwindError as exc: raise HomeAssistantError( translation_domain=DOMAIN, @@ -109,6 +112,8 @@ class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): translation_domain=DOMAIN, translation_key="door_locked_out", ) from exc + except TailwindDoorAlreadyInStateError: + LOGGER.debug("Already in the requested state: %s", self.entity_id) except TailwindError as exc: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/tests/components/tailwind/test_cover.py b/tests/components/tailwind/test_cover.py index 8ccb8947624..a658f842885 100644 --- a/tests/components/tailwind/test_cover.py +++ b/tests/components/tailwind/test_cover.py @@ -3,6 +3,7 @@ from unittest.mock import ANY, MagicMock from gotailwind import ( + TailwindDoorAlreadyInStateError, TailwindDoorDisabledError, TailwindDoorLockedOutError, TailwindDoorOperationCommand, @@ -181,3 +182,28 @@ async def test_cover_operations( ) assert excinfo.value.translation_domain == DOMAIN assert excinfo.value.translation_key == "communication_error" + + # Test door already in state + mock_tailwind.operate.side_effect = TailwindDoorAlreadyInStateError( + "Door is already in the requested state" + ) + + # This call should not raise an exception + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + # This call should not raise an exception + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) From 53a2777831f34d50c8ab04597a4f5c363eb443ed Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Tue, 1 Oct 2024 05:52:54 -0700 Subject: [PATCH 098/134] Update prometheus-client to 0.21.0 (#126965) --- homeassistant/components/prometheus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/prometheus/manifest.json b/homeassistant/components/prometheus/manifest.json index cb8defb2ed5..8c43be8539d 100644 --- a/homeassistant/components/prometheus/manifest.json +++ b/homeassistant/components/prometheus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prometheus", "iot_class": "assumed_state", "loggers": ["prometheus_client"], - "requirements": ["prometheus-client==0.17.1"] + "requirements": ["prometheus-client==0.21.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 76fa06a3972..4130f765bd9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1630,7 +1630,7 @@ prayer-times-calculator-offline==1.0.3 proliphix==0.4.1 # homeassistant.components.prometheus -prometheus-client==0.17.1 +prometheus-client==0.21.0 # homeassistant.components.proxmoxve proxmoxer==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d3bb326df0..367d98fdc71 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1331,7 +1331,7 @@ praw==7.5.0 prayer-times-calculator-offline==1.0.3 # homeassistant.components.prometheus -prometheus-client==0.17.1 +prometheus-client==0.21.0 # homeassistant.components.hardware # homeassistant.components.recorder From bce7552d4defae4700cf98b6795fbcc704536b61 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Sep 2024 19:52:11 +0200 Subject: [PATCH 099/134] Update gotailwind to 0.2.4 (#127129) --- homeassistant/components/tailwind/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index 2cc5f04fd16..97d08737a87 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -12,7 +12,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["gotailwind==0.2.3"], + "requirements": ["gotailwind==0.2.4"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 4130f765bd9..347d5351163 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1016,7 +1016,7 @@ googlemaps==2.5.1 goslide-api==0.5.1 # homeassistant.components.tailwind -gotailwind==0.2.3 +gotailwind==0.2.4 # homeassistant.components.govee_ble govee-ble==0.40.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 367d98fdc71..4eeb4211094 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -863,7 +863,7 @@ google-photos-library-api==0.12.1 googlemaps==2.5.1 # homeassistant.components.tailwind -gotailwind==0.2.3 +gotailwind==0.2.4 # homeassistant.components.govee_ble govee-ble==0.40.0 From 03553b8bb913075028f84e6e3b52d171e0c6e60e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:17:50 +0200 Subject: [PATCH 100/134] Use reconfigure_confirm in homeworks config flow (#127218) * Use reconfigure_confirm in homeworks config flow * Fix tests --- .../components/homeworks/config_flow.py | 27 ++++++++++++------- .../components/homeworks/strings.json | 6 ++--- .../components/homeworks/test_config_flow.py | 12 ++++----- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py index 3d947e3d599..6fc87bda007 100644 --- a/homeassistant/components/homeworks/config_flow.py +++ b/homeassistant/components/homeworks/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from functools import partial import logging from typing import Any @@ -557,6 +558,8 @@ OPTIONS_FLOW = { class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for Lutron Homeworks.""" + _context_entry: ConfigEntry + async def _validate_edit_controller( self, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -580,18 +583,24 @@ class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN): return user_input async def async_step_reconfigure( - self, user_input: dict[str, Any] | None = None + self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle a reconfigure flow.""" entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert entry + self._context_entry = entry + return await self.async_step_reconfigure_confirm() + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfigure flow.""" errors = {} suggested_values = { - CONF_HOST: entry.options[CONF_HOST], - CONF_PORT: entry.options[CONF_PORT], - CONF_USERNAME: entry.data.get(CONF_USERNAME), - CONF_PASSWORD: entry.data.get(CONF_PASSWORD), + CONF_HOST: self._context_entry.options[CONF_HOST], + CONF_PORT: self._context_entry.options[CONF_PORT], + CONF_USERNAME: self._context_entry.data.get(CONF_USERNAME), + CONF_PASSWORD: self._context_entry.data.get(CONF_PASSWORD), } if user_input: @@ -608,16 +617,16 @@ class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN): else: password = user_input.pop(CONF_PASSWORD, None) username = user_input.pop(CONF_USERNAME, None) - new_data = entry.data | { + new_data = self._context_entry.data | { CONF_PASSWORD: password, CONF_USERNAME: username, } - new_options = entry.options | { + new_options = self._context_entry.options | { CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT], } return self.async_update_reload_and_abort( - entry, + self._context_entry, data=new_data, options=new_options, reason="reconfigure_successful", @@ -625,7 +634,7 @@ class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="reconfigure", + step_id="reconfigure_confirm", data_schema=self.add_suggested_values_to_schema( DATA_SCHEMA_EDIT_CONTROLLER, suggested_values ), diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index a9dcab2f1e0..c2c8a14f77c 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -22,7 +22,7 @@ "name": "[%key:component::homeworks::config::step::user::data_description::name%]" } }, - "reconfigure": { + "reconfigure_confirm": { "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -45,8 +45,8 @@ }, "data_description": { "name": "A unique name identifying the Lutron Homeworks controller", - "password": "[%key:component::homeworks::config::step::reconfigure::data_description::password%]", - "username": "[%key:component::homeworks::config::step::reconfigure::data_description::username%]" + "password": "[%key:component::homeworks::config::step::reconfigure_confirm::data_description::password%]", + "username": "[%key:component::homeworks::config::step::reconfigure_confirm::data_description::username%]" }, "description": "Add a Lutron Homeworks controller" } diff --git a/tests/components/homeworks/test_config_flow.py b/tests/components/homeworks/test_config_flow.py index d0693531006..f9deb2c20c9 100644 --- a/tests/components/homeworks/test_config_flow.py +++ b/tests/components/homeworks/test_config_flow.py @@ -246,7 +246,7 @@ async def test_reconfigure_flow( context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -314,7 +314,7 @@ async def test_reconfigure_flow_flow_duplicate( context={"source": SOURCE_RECONFIGURE, "entry_id": entry1.entry_id}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -324,7 +324,7 @@ async def test_reconfigure_flow_flow_duplicate( }, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" assert result["errors"] == {"base": "duplicated_host_port"} @@ -339,7 +339,7 @@ async def test_reconfigure_flow_flow_no_change( context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -387,7 +387,7 @@ async def test_reconfigure_flow_credentials_password_only( context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -398,7 +398,7 @@ async def test_reconfigure_flow_credentials_password_only( }, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" assert result["errors"] == {"base": "need_username_with_password"} From 067b81a60be9f44859b5db7b4d5bffcf953739c4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:11:37 +0200 Subject: [PATCH 101/134] Use reconfigure_confirm in enphase_envoy config flow (#127221) --- .../components/enphase_envoy/config_flow.py | 21 ++++++++++++------- .../components/enphase_envoy/strings.json | 2 +- .../enphase_envoy/test_config_flow.py | 8 +++---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index c18401859de..52e4ee7ec28 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -54,6 +54,8 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _reconnect_entry: ConfigEntry + def __init__(self) -> None: """Initialize an envoy flow.""" self.ip_address: str | None = None @@ -233,17 +235,22 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_reconfigure( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Add reconfigure step to allow to manually reconfigure a config entry.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry + self._reconnect_entry = entry + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Add reconfigure step to allow to manually reconfigure a config entry.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} - - entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) - assert entry - suggested_values: dict[str, Any] | MappingProxyType[str, Any] = ( - user_input or entry.data + user_input or self._reconnect_entry.data ) host: Any = suggested_values.get(CONF_HOST) @@ -284,7 +291,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): error="reconfigure_successful", ) if not self.unique_id: - await self.async_set_unique_id(entry.unique_id) + await self.async_set_unique_id(self._reconnect_entry.unique_id) self.context["title_placeholders"] = { CONF_SERIAL: self.unique_id, @@ -292,7 +299,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): } return self.async_show_form( - step_id="reconfigure", + step_id="reconfigure_confirm", data_schema=self.add_suggested_values_to_schema( self._async_generate_schema(), suggested_values ), diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 2e7ce831efc..c08a6c53a0f 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -13,7 +13,7 @@ "host": "The hostname or IP address of your Enphase Envoy gateway." } }, - "reconfigure": { + "reconfigure_confirm": { "description": "[%key:component::enphase_envoy::config::step::user::description%]", "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index f61a0054ed9..42e41051e0a 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -706,7 +706,7 @@ async def test_reconfigure( }, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" assert result["errors"] == {} # original entry @@ -748,7 +748,7 @@ async def test_reconfigure_nochange( }, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" assert result["errors"] == {} # original entry @@ -790,7 +790,7 @@ async def test_reconfigure_otherenvoy( }, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" assert result["errors"] == {} # let mock return different serial from first time, sim it's other one on changed ip @@ -936,7 +936,7 @@ async def test_reconfigure_change_ip_to_existing( }, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + assert result["step_id"] == "reconfigure_confirm" assert result["errors"] == {} # original entry From 88ff94dd6964b058ae2927059654daa9627ad0a6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Oct 2024 22:08:14 +0200 Subject: [PATCH 102/134] Use reconfigure_confirm in bryant_evolution config flow (#127222) --- .../components/bryant_evolution/config_flow.py | 11 ++++++++++- .../components/bryant_evolution/strings.json | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bryant_evolution/config_flow.py b/homeassistant/components/bryant_evolution/config_flow.py index a6b07daf96b..9cfb9b2ec7e 100644 --- a/homeassistant/components/bryant_evolution/config_flow.py +++ b/homeassistant/components/bryant_evolution/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -61,6 +62,12 @@ class BryantConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_reconfigure( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle integration reconfiguration.""" + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle integration reconfiguration.""" @@ -83,5 +90,7 @@ class BryantConfigFlow(ConfigFlow, domain=DOMAIN): ) errors["base"] = "cannot_connect" return self.async_show_form( - step_id="reconfigure", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="reconfigure_confirm", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, ) diff --git a/homeassistant/components/bryant_evolution/strings.json b/homeassistant/components/bryant_evolution/strings.json index 1ce9d58bb10..d446fdc5345 100644 --- a/homeassistant/components/bryant_evolution/strings.json +++ b/homeassistant/components/bryant_evolution/strings.json @@ -1,6 +1,11 @@ { "config": { "step": { + "reconfigure": { + "data": { + "filename": "[%key:component::bryant_evolution::config::step::user::data::filename%]" + } + }, "user": { "data": { "filename": "Serial port filename" From df6edd09c0a8f5653ebd9c7c48d8703ef0a5efa5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Oct 2024 22:08:48 +0200 Subject: [PATCH 103/134] Don't create statistics issues when sensor is unavailable or unknown (#127226) --- homeassistant/components/sensor/recorder.py | 16 ++++- tests/components/sensor/test_recorder.py | 74 +++++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index be0feb7fa52..59f20a9ed25 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Callable, Iterable +from contextlib import suppress import datetime from functools import partial import itertools @@ -179,6 +180,14 @@ def _entity_history_to_float_and_state( return float_states +def _is_numeric(state: State) -> bool: + """Return if the state is numeric.""" + with suppress(ValueError, TypeError): + if (num_state := float(state.state)) is not None and math.isfinite(num_state): + return True + return False + + def _normalize_states( hass: HomeAssistant, old_metadatas: dict[str, tuple[int, StatisticMetaData]], @@ -684,13 +693,14 @@ def _update_issues( """Update repair issues.""" for state in sensor_states: entity_id = state.entity_id + numeric = _is_numeric(state) state_class = try_parse_enum( SensorStateClass, state.attributes.get(ATTR_STATE_CLASS) ) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if metadata := metadatas.get(entity_id): - if state_class is None: + if numeric and state_class is None: # Sensor no longer has a valid state class report_issue( "state_class_removed", @@ -703,7 +713,7 @@ def _update_issues( metadata_unit = metadata[1]["unit_of_measurement"] converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER.get(metadata_unit) if not converter: - if not _equivalent_units({state_unit, metadata_unit}): + if numeric and not _equivalent_units({state_unit, metadata_unit}): # The unit has changed, and it's not possible to convert report_issue( "units_changed", @@ -717,7 +727,7 @@ def _update_issues( ) else: clear_issue("units_changed", entity_id) - elif state_unit not in converter.VALID_UNITS: + elif numeric and state_unit not in converter.VALID_UNITS: # The state unit can't be converted to the unit in metadata valid_units = (unit or "" for unit in converter.VALID_UNITS) valid_units_str = ", ".join(sorted(valid_units)) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 77bb6e17f68..04e0a1b7de8 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4332,6 +4332,26 @@ async def test_validate_unit_change_convertible( } await assert_validation_result(hass, client, expected, {"units_changed"}) + # Unavailable state - empty response + hass.states.async_set( + "sensor.test", + "unavailable", + attributes={**attributes, "unit_of_measurement": "dogs"}, + timestamp=now.timestamp(), + ) + await async_recorder_block_till_done(hass) + await assert_validation_result(hass, client, {}, {}) + + # Unknown state - empty response + hass.states.async_set( + "sensor.test", + "unknown", + attributes={**attributes, "unit_of_measurement": "dogs"}, + timestamp=now.timestamp(), + ) + await async_recorder_block_till_done(hass) + await assert_validation_result(hass, client, {}, {}) + # Valid state - empty response hass.states.async_set( "sensor.test", @@ -4531,6 +4551,26 @@ async def test_validate_statistics_unit_change_no_device_class( } await assert_validation_result(hass, client, expected, {"units_changed"}) + # Unavailable state - empty response + hass.states.async_set( + "sensor.test", + "unavailable", + attributes={**attributes, "unit_of_measurement": "dogs"}, + timestamp=now.timestamp(), + ) + await async_recorder_block_till_done(hass) + await assert_validation_result(hass, client, {}, {}) + + # Unknown state - empty response + hass.states.async_set( + "sensor.test", + "unknown", + attributes={**attributes, "unit_of_measurement": "dogs"}, + timestamp=now.timestamp(), + ) + await async_recorder_block_till_done(hass) + await assert_validation_result(hass, client, {}, {}) + # Valid state - empty response hass.states.async_set( "sensor.test", @@ -4627,6 +4667,20 @@ async def test_validate_statistics_state_class_removed( } await assert_validation_result(hass, client, expected, {"state_class_removed"}) + # Unavailable state - empty response + hass.states.async_set( + "sensor.test", "unavailable", attributes=_attributes, timestamp=now.timestamp() + ) + await async_recorder_block_till_done(hass) + await assert_validation_result(hass, client, {}, {}) + + # Unknown state - empty response + hass.states.async_set( + "sensor.test", "unknown", attributes=_attributes, timestamp=now.timestamp() + ) + await async_recorder_block_till_done(hass) + await assert_validation_result(hass, client, {}, {}) + @pytest.mark.parametrize( ("units", "attributes", "unit"), @@ -4871,6 +4925,26 @@ async def test_validate_statistics_unit_change_no_conversion( } await assert_validation_result(hass, client, expected, {"units_changed"}) + # Unavailable state - empty response + hass.states.async_set( + "sensor.test", + "unavailable", + attributes={**attributes, "unit_of_measurement": unit2}, + timestamp=now.timestamp(), + ) + await async_recorder_block_till_done(hass) + await assert_validation_result(hass, client, {}, {}) + + # Unknown state - empty response + hass.states.async_set( + "sensor.test", + "unknown", + attributes={**attributes, "unit_of_measurement": unit2}, + timestamp=now.timestamp(), + ) + await async_recorder_block_till_done(hass) + await assert_validation_result(hass, client, {}, {}) + # Original unit - empty response hass.states.async_set( "sensor.test", From 60079a14e7f9eb953f05d5adee66a2b1b42ae7bb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Oct 2024 22:06:56 +0200 Subject: [PATCH 104/134] Update log error message for Samsung TV (#127231) --- homeassistant/components/samsungtv/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 1dfd3f00b93..b43b8abea65 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -249,7 +249,7 @@ async def _async_create_bridge_with_updated_data( updated_data[CONF_MODEL] = model if model_requires_encryption(model) and method != METHOD_ENCRYPTED_WEBSOCKET: - LOGGER.warning( + LOGGER.debug( ( "Detected model %s for %s. Some televisions from H and J series use " "an encrypted protocol but you are using %s which may not be supported" From 749a5b37c9647cbbc10af314a5c2cd9a7235b2e6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 1 Oct 2024 22:14:57 +0200 Subject: [PATCH 105/134] Bump version to 2024.10.0b8 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1351d288b7a..3d90fbc0663 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 5ec1bf4beda..a9127b5c896 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b7" +version = "2024.10.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 8c8a2eef21a4946eee75d9e355b05d37dc22ab31 Mon Sep 17 00:00:00 2001 From: functionpointer Date: Wed, 2 Oct 2024 08:43:31 +0200 Subject: [PATCH 106/134] Fix Tibber get_prices when called with aware datetime (#123289) * Tibber: Add extra test to expose aware/naive datetime issue * Tibber: Fix get_prices action not working with aware datetimes * Tibber: Simplify comparison * Tibber: Combine timezone tests into single parametrized one * Tibber: Split test again to prevent if statement --- homeassistant/components/tibber/services.py | 13 ++-- tests/components/tibber/test_services.py | 74 ++++++++++++++++++++- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index 82353bb78d7..35facbcd545 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -3,7 +3,7 @@ from __future__ import annotations import datetime as dt -from datetime import date, datetime +from datetime import datetime from functools import partial from typing import Any, Final @@ -61,27 +61,24 @@ async def __get_prices(call: ServiceCall, *, hass: HomeAssistant) -> ServiceResp ] selected_data = [ - price - for price in price_data - if price["start_time"].replace(tzinfo=None) >= start - and price["start_time"].replace(tzinfo=None) < end + price for price in price_data if start <= price["start_time"] < end ] tibber_prices[home_nickname] = selected_data return {"prices": tibber_prices} -def __get_date(date_input: str | None, mode: str | None) -> date | datetime: +def __get_date(date_input: str | None, mode: str | None) -> datetime: """Get date.""" if not date_input: if mode == "end": increment = dt.timedelta(days=1) else: increment = dt.timedelta() - return datetime.fromisoformat(dt_util.now().date().isoformat()) + increment + return dt_util.start_of_local_day() + increment if value := dt_util.parse_datetime(date_input): - return value + return dt_util.as_local(value) raise ServiceValidationError( "Invalid datetime provided.", diff --git a/tests/components/tibber/test_services.py b/tests/components/tibber/test_services.py index e9bee3ba31f..1df91d719fe 100644 --- a/tests/components/tibber/test_services.py +++ b/tests/components/tibber/test_services.py @@ -11,8 +11,11 @@ from homeassistant.components.tibber.const import DOMAIN from homeassistant.components.tibber.services import PRICE_SERVICE_NAME, __get_prices from homeassistant.core import ServiceCall from homeassistant.exceptions import ServiceValidationError +from homeassistant.util import dt as dt_util -STARTTIME = dt.datetime.fromtimestamp(1615766400) +STARTTIME = dt.datetime.fromtimestamp(1615766400).replace( + tzinfo=dt_util.get_default_time_zone() +) def generate_mock_home_data(): @@ -246,6 +249,75 @@ async def test_get_prices_start_tomorrow( } +@pytest.mark.parametrize( + "start_time", + [ + STARTTIME.isoformat(), + STARTTIME.replace(tzinfo=None).isoformat(), + (STARTTIME + dt.timedelta(hours=4)) + .replace(tzinfo=dt.timezone(dt.timedelta(hours=4))) + .isoformat(), + ], +) +async def test_get_prices_with_timezones( + freezer: FrozenDateTimeFactory, + start_time: str, +) -> None: + """Test __get_prices with timezone and without.""" + freezer.move_to(STARTTIME) + call = ServiceCall(DOMAIN, PRICE_SERVICE_NAME, {"start": start_time}) + + result = await __get_prices(call, hass=create_mock_hass()) + + assert result == { + "prices": { + "first_home": [ + { + "start_time": STARTTIME, + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "start_time": STARTTIME + dt.timedelta(hours=1), + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + "second_home": [ + { + "start_time": STARTTIME, + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "start_time": STARTTIME + dt.timedelta(hours=1), + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + } + } + + +@pytest.mark.parametrize( + "start_time", + [ + (STARTTIME + dt.timedelta(hours=4)).isoformat(), + (STARTTIME + dt.timedelta(hours=4)).replace(tzinfo=None).isoformat(), + ], +) +async def test_get_prices_with_wrong_timezones( + freezer: FrozenDateTimeFactory, + start_time: str, +) -> None: + """Test __get_prices with timezone and without, while expecting it to fail.""" + freezer.move_to(STARTTIME) + call = ServiceCall(DOMAIN, PRICE_SERVICE_NAME, {"start": start_time}) + + result = await __get_prices(call, hass=create_mock_hass()) + assert result == {"prices": {"first_home": [], "second_home": []}} + + async def test_get_prices_invalid_input() -> None: """Test __get_prices with invalid input.""" From 49708196acf9c574d97017206b246475fd0c37a6 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 1 Oct 2024 16:58:47 -0500 Subject: [PATCH 107/134] Run unsubscribe callbacks when Assist satellite entity is removed from HA (#127234) * Unsubscribe when removed from HA * Use builtin async_on_remove --- homeassistant/components/esphome/assist_satellite.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 44d4a16761d..b2794fe043f 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -212,7 +212,7 @@ class EsphomeAssistSatellite( ) if feature_flags & VoiceAssistantFeature.API_AUDIO: # TCP audio - self.entry_data.disconnect_callbacks.add( + self.async_on_remove( self.cli.subscribe_voice_assistant( handle_start=self.handle_pipeline_start, handle_stop=self.handle_pipeline_stop, @@ -222,7 +222,7 @@ class EsphomeAssistSatellite( ) else: # UDP audio - self.entry_data.disconnect_callbacks.add( + self.async_on_remove( self.cli.subscribe_voice_assistant( handle_start=self.handle_pipeline_start, handle_stop=self.handle_pipeline_stop, @@ -235,7 +235,7 @@ class EsphomeAssistSatellite( assert (self.registry_entry is not None) and ( self.registry_entry.device_id is not None ) - self.entry_data.disconnect_callbacks.add( + self.async_on_remove( async_register_timer_handler( self.hass, self.registry_entry.device_id, self.handle_timer_event ) From fcf91954ffc62d829baa6909c1812593d0809607 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 2 Oct 2024 08:27:52 +0200 Subject: [PATCH 108/134] Remove codefences from issue titles (#127254) --- homeassistant/components/calendar/strings.json | 2 +- homeassistant/components/cloud/strings.json | 2 +- homeassistant/components/habitica/strings.json | 2 +- homeassistant/components/homeassistant/strings.json | 4 ++-- homeassistant/components/modbus/strings.json | 8 ++++---- homeassistant/components/notify/strings.json | 2 +- homeassistant/components/ring/strings.json | 2 +- homeassistant/components/technove/strings.json | 2 +- homeassistant/components/tplink/strings.json | 2 +- homeassistant/components/weather/strings.json | 2 +- 10 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 1b6037781df..5b76a33f7c3 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -111,7 +111,7 @@ }, "issues": { "deprecated_service_calendar_list_events": { - "title": "Detected use of deprecated action `calendar.list_events`", + "title": "Detected use of deprecated action calendar.list_events", "fix_flow": { "step": { "confirm": { diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index b71ccc0dfa0..fe36159e5eb 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -25,7 +25,7 @@ }, "issues": { "deprecated_gender": { - "title": "The `{deprecated_option}` text-to-speech option is deprecated", + "title": "The {deprecated_option} text-to-speech option is deprecated", "fix_flow": { "step": { "confirm": { diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index c5a54d254cc..c9f0829215e 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -152,7 +152,7 @@ }, "issues": { "deprecated_task_entity": { - "title": "The Habitica `{task_name}` sensor is deprecated", + "title": "The Habitica {task_name} sensor is deprecated", "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`." } }, diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index f0789b17ab2..29612bd61ed 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -19,7 +19,7 @@ "description": "The currency {currency} is no longer in use, please reconfigure the currency configuration." }, "legacy_templates_false": { - "title": "`legacy_templates` config key is being removed", + "title": "legacy_templates config key is being removed", "description": "Nothing will change with your templates.\n\nRemove the `legacy_templates` key from the `homeassistant` configuration in your configuration.yaml file and restart Home Assistant to fix this issue." }, "legacy_templates_true": { @@ -43,7 +43,7 @@ "description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}" }, "storage_corruption": { - "title": "Storage corruption detected for `{storage_key}`", + "title": "Storage corruption detected for {storage_key}", "fix_flow": { "step": { "confirm": { diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 8e746ca1299..c0d702a9b89 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -71,15 +71,15 @@ }, "issues": { "removed_lazy_error_count": { - "title": "`{config_key}` configuration key is being removed", + "title": "{config_key} configuration key is being removed", "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue. All errors will be reported, as lazy_error_count is accepted but ignored" }, "deprecated_retries": { - "title": "`{config_key}` configuration key is being removed", + "title": "{config_key} configuration key is being removed", "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nThe maximum number of retries is now fixed to 3." }, "missing_modbus_name": { - "title": "Modbus entry with host `{sub_2}` missing name", + "title": "Modbus entry with host {sub_2} missing name", "description": "Please add `{sub_1}` key to the {integration} entry with host `{sub_2}` in your configuration.yaml file and restart Home Assistant to fix this issue\n\n. `{sub_1}: {sub_3}` have been added." }, "duplicate_modbus_entry": { @@ -99,7 +99,7 @@ "description": "Please add at least one entity to Modbus {sub_1} in your configuration.yaml file and restart Home Assistant to fix this issue." }, "deprecated_restart": { - "title": "`modbus.restart` is being removed", + "title": "modbus.restart is being removed", "description": "Please use reload yaml via the developer tools in the UI instead of via the `modbus.restart` action." } } diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json index 3fba5e43fc7..d1deca0a6c4 100644 --- a/homeassistant/components/notify/strings.json +++ b/homeassistant/components/notify/strings.json @@ -74,7 +74,7 @@ } }, "migrate_notify_service": { - "title": "Legacy action `notify.{service_name}` stll being used", + "title": "Legacy action notify.{service_name} stll being used", "fix_flow": { "step": { "confirm": { diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index da0a8af5324..5d282fae1b2 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -120,7 +120,7 @@ }, "issues": { "deprecated_entity": { - "title": "Detected deprecated `{platform}` entity usage", + "title": "Detected deprecated {platform} entity usage", "description": "We detected that entity `{entity}` is being used in `{info}`\n\nWe have created a new `{new_platform}` entity and you should migrate `{info}` to use this new entity.\n\nWhen you are done migrating `{info}` and are ready to have the deprecated `{entity}` entity removed, disable the entity and restart Home Assistant." } } diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 06c93939db8..7175b7c2de5 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -93,7 +93,7 @@ }, "issues": { "deprecated_entity_is_session_active": { - "title": "The TechnoVE `{sensor_name}` binary sensor is deprecated", + "title": "The TechnoVE {sensor_name} binary sensor is deprecated", "description": "`{entity}` is deprecated.\nPlease update your automations and scripts to replace the binary sensor entity with the newly added switch entity.\nWhen you are done migrating you can disable `{entity}`." } } diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 2afc46a5ff1..fd63a1031d3 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -314,7 +314,7 @@ }, "issues": { "deprecated_entity": { - "title": "Detected deprecated `{platform}` entity usage", + "title": "Detected deprecated {platform} entity usage", "description": "We detected that entity `{entity}` is being used in `{info}`\n\nWe have created a new `{new_platform}` entity and you should migrate `{info}` to use this new entity.\n\nWhen you are done migrating `{info}` and are ready to have the deprecated `{entity}` entity removed, disable the entity and restart Home Assistant." } } diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 521d8ab9afe..85d331f5bd0 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -111,7 +111,7 @@ }, "issues": { "deprecated_service_weather_get_forecast": { - "title": "Detected use of deprecated service `weather.get_forecast`", + "title": "Detected use of deprecated service weather.get_forecast", "fix_flow": { "step": { "confirm": { From b8fd921c81866dde38d090fa9854f31f81f477e0 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 2 Oct 2024 06:48:47 +0200 Subject: [PATCH 109/134] Revert "Support Z-Wave JS dimming lights using color intensity (#122639)" (#127256) This reverts commit c7cfd56b720be8212af2686ecfa5b8cad6ee299b. --- .../components/zwave_js/discovery.py | 55 +- homeassistant/components/zwave_js/light.py | 281 ++----- tests/components/zwave_js/test_light.py | 752 ++++++------------ 3 files changed, 352 insertions(+), 736 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 63f91d5b83d..cff0eb434e0 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -238,12 +238,6 @@ SWITCH_BINARY_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( command_class={CommandClass.SWITCH_BINARY}, property={CURRENT_VALUE_PROPERTY} ) -COLOR_SWITCH_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( - command_class={CommandClass.SWITCH_COLOR}, - property={CURRENT_COLOR_PROPERTY}, - property_key={None}, -) - SIREN_TONE_SCHEMA = ZWaveValueDiscoverySchema( command_class={CommandClass.SOUND_SWITCH}, property={TONE_ID_PROPERTY}, @@ -768,6 +762,33 @@ DISCOVERY_SCHEMAS = [ }, ), ), + # HomeSeer HSM-200 v1 + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + hint="black_is_off", + manufacturer_id={0x001E}, + product_id={0x0001}, + product_type={0x0004}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_COLOR}, + property={CURRENT_COLOR_PROPERTY}, + property_key={None}, + ), + absent_values=[SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA], + ), + # Logic Group ZDB5100 + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + hint="black_is_off", + manufacturer_id={0x0234}, + product_id={0x0121}, + product_type={0x0003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_COLOR}, + property={CURRENT_COLOR_PROPERTY}, + property_key={None}, + ), + ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks # Door Lock CC @@ -969,11 +990,10 @@ DISCOVERY_SCHEMAS = [ ), entity_category=EntityCategory.CONFIG, ), - # binary switches without color support + # binary switches ZWaveDiscoverySchema( platform=Platform.SWITCH, primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, - absent_values=[COLOR_SWITCH_CURRENT_VALUE_SCHEMA], ), # switch for Indicator CC ZWaveDiscoverySchema( @@ -1067,25 +1087,6 @@ DISCOVERY_SCHEMAS = [ # catch any device with multilevel CC as light # NOTE: keep this at the bottom of the discovery scheme, # to handle all others that need the multilevel CC first - # - # Colored light (legacy device) that can only be controlled through Color Switch CC. - ZWaveDiscoverySchema( - platform=Platform.LIGHT, - hint="color_onoff", - primary_value=COLOR_SWITCH_CURRENT_VALUE_SCHEMA, - absent_values=[ - SWITCH_BINARY_CURRENT_VALUE_SCHEMA, - SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, - ], - ), - # Colored light that can be turned on or off with the Binary Switch CC. - ZWaveDiscoverySchema( - platform=Platform.LIGHT, - hint="color_onoff", - primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, - required_values=[COLOR_SWITCH_CURRENT_VALUE_SCHEMA], - ), - # Dimmable light with or without color support. ZWaveDiscoverySchema( platform=Platform.LIGHT, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 4a044ca3f52..020f1b66b3d 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -76,8 +76,8 @@ async def async_setup_entry( driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. - if info.platform_hint == "color_onoff": - async_add_entities([ZwaveColorOnOffLight(config_entry, driver, info)]) + if info.platform_hint == "black_is_off": + async_add_entities([ZwaveBlackIsOffLight(config_entry, driver, info)]) else: async_add_entities([ZwaveLight(config_entry, driver, info)]) @@ -111,10 +111,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._supports_color = False self._supports_rgbw = False self._supports_color_temp = False - self._supports_dimming = False - self._color_mode: str | None = None self._hs_color: tuple[float, float] | None = None self._rgbw_color: tuple[int, int, int, int] | None = None + self._color_mode: str | None = None self._color_temp: int | None = None self._min_mireds = 153 # 6500K as a safe default self._max_mireds = 370 # 2700K as a safe default @@ -130,28 +129,15 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): ) self._supported_color_modes: set[ColorMode] = set() - self._target_brightness: Value | None = None - # get additional (optional) values and set features - if self.info.primary_value.command_class == CommandClass.SWITCH_BINARY: - # This light can not be dimmed separately from the color channels - self._target_brightness = self.get_zwave_value( - TARGET_VALUE_PROPERTY, - CommandClass.SWITCH_BINARY, - add_to_watched_value_ids=False, - ) - self._supports_dimming = False - elif self.info.primary_value.command_class == CommandClass.SWITCH_MULTILEVEL: - # This light can be dimmed separately from the color channels - self._target_brightness = self.get_zwave_value( - TARGET_VALUE_PROPERTY, - CommandClass.SWITCH_MULTILEVEL, - add_to_watched_value_ids=False, - ) - self._supports_dimming = True - elif self.info.primary_value.command_class == CommandClass.BASIC: - # If the command class is Basic, we must generate a name that includes - # the command class name to avoid ambiguity + # If the command class is Basic, we must geenerate a name that includes + # the command class name to avoid ambiguity + self._target_brightness = self.get_zwave_value( + TARGET_VALUE_PROPERTY, + CommandClass.SWITCH_MULTILEVEL, + add_to_watched_value_ids=False, + ) + if self.info.primary_value.command_class == CommandClass.BASIC: self._attr_name = self.generate_name( include_value_name=True, alternate_value_name="Basic" ) @@ -160,13 +146,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): CommandClass.BASIC, add_to_watched_value_ids=False, ) - self._supports_dimming = True - - self._current_color = self.get_zwave_value( - CURRENT_COLOR_PROPERTY, - CommandClass.SWITCH_COLOR, - value_property_key=None, - ) self._target_color = self.get_zwave_value( TARGET_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, @@ -237,7 +216,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): @property def rgbw_color(self) -> tuple[int, int, int, int] | None: - """Return the RGBW color.""" + """Return the hs color.""" return self._rgbw_color @property @@ -264,39 +243,11 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): """Turn the device on.""" transition = kwargs.get(ATTR_TRANSITION) - brightness = kwargs.get(ATTR_BRIGHTNESS) - - hs_color = kwargs.get(ATTR_HS_COLOR) - color_temp = kwargs.get(ATTR_COLOR_TEMP) - rgbw = kwargs.get(ATTR_RGBW_COLOR) - - new_colors = self._get_new_colors(hs_color, color_temp, rgbw) - if new_colors is not None: - await self._async_set_colors(new_colors, transition) - - # set brightness (or turn on if dimming is not supported) - await self._async_set_brightness(brightness, transition) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the light off.""" - await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION)) - - def _get_new_colors( - self, - hs_color: tuple[float, float] | None, - color_temp: int | None, - rgbw: tuple[int, int, int, int] | None, - brightness_scale: float | None = None, - ) -> dict[ColorComponent, int] | None: - """Determine the new color dict to set.""" # RGB/HS color + hs_color = kwargs.get(ATTR_HS_COLOR) if hs_color is not None and self._supports_color: red, green, blue = color_util.color_hs_to_RGB(*hs_color) - if brightness_scale is not None: - red = round(red * brightness_scale) - green = round(green * brightness_scale) - blue = round(blue * brightness_scale) colors = { ColorComponent.RED: red, ColorComponent.GREEN: green, @@ -306,9 +257,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # turn of white leds when setting rgb colors[ColorComponent.WARM_WHITE] = 0 colors[ColorComponent.COLD_WHITE] = 0 - return colors + await self._async_set_colors(colors, transition) # Color temperature + color_temp = kwargs.get(ATTR_COLOR_TEMP) if color_temp is not None and self._supports_color_temp: # Limit color temp to min/max values cold = max( @@ -323,18 +275,20 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): ), ) warm = 255 - cold - colors = { - ColorComponent.WARM_WHITE: warm, - ColorComponent.COLD_WHITE: cold, - } - if self._supports_color: - # turn off color leds when setting color temperature - colors[ColorComponent.RED] = 0 - colors[ColorComponent.GREEN] = 0 - colors[ColorComponent.BLUE] = 0 - return colors + await self._async_set_colors( + { + # turn off color leds when setting color temperature + ColorComponent.RED: 0, + ColorComponent.GREEN: 0, + ColorComponent.BLUE: 0, + ColorComponent.WARM_WHITE: warm, + ColorComponent.COLD_WHITE: cold, + }, + transition, + ) # RGBW + rgbw = kwargs.get(ATTR_RGBW_COLOR) if rgbw is not None and self._supports_rgbw: rgbw_channels = { ColorComponent.RED: rgbw[0], @@ -346,15 +300,17 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if self._cold_white: rgbw_channels[ColorComponent.COLD_WHITE] = rgbw[3] + await self._async_set_colors(rgbw_channels, transition) - return rgbw_channels + # set brightness + await self._async_set_brightness(kwargs.get(ATTR_BRIGHTNESS), transition) - return None + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION)) async def _async_set_colors( - self, - colors: dict[ColorComponent, int], - transition: float | None = None, + self, colors: dict[ColorComponent, int], transition: float | None = None ) -> None: """Set (multiple) defined colors to given value(s).""" # prefer the (new) combined color property @@ -405,14 +361,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): zwave_transition = {TRANSITION_DURATION_OPTION: "default"} # setting a value requires setting targetValue - if self._supports_dimming: - await self._async_set_value( - self._target_brightness, zwave_brightness, zwave_transition - ) - else: - await self._async_set_value( - self._target_brightness, zwave_brightness > 0, zwave_transition - ) + await self._async_set_value( + self._target_brightness, zwave_brightness, zwave_transition + ) # We do an optimistic state update when setting to a previous value # to avoid waiting for the value to be updated from the device which is # typically delayed and causes a confusing UX. @@ -476,8 +427,15 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): """Calculate light colors.""" (red_val, green_val, blue_val, ww_val, cw_val) = self._get_color_values() - if self._current_color and isinstance(self._current_color.value, dict): - multi_color = self._current_color.value + # prefer the (new) combined color property + # https://github.com/zwave-js/node-zwave-js/pull/1782 + combined_color_val = self.get_zwave_value( + CURRENT_COLOR_PROPERTY, + CommandClass.SWITCH_COLOR, + value_property_key=None, + ) + if combined_color_val and isinstance(combined_color_val.value, dict): + multi_color = combined_color_val.value else: multi_color = {} @@ -528,10 +486,11 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._color_mode = ColorMode.RGBW -class ZwaveColorOnOffLight(ZwaveLight): - """Representation of a colored Z-Wave light with an optional binary switch to turn on/off. +class ZwaveBlackIsOffLight(ZwaveLight): + """Representation of a Z-Wave light where setting the color to black turns it off. - Dimming for RGB lights is realized by scaling the color channels. + Currently only supports lights with RGB, no color temperature, and no white + channels. """ def __init__( @@ -540,137 +499,61 @@ class ZwaveColorOnOffLight(ZwaveLight): """Initialize the light.""" super().__init__(config_entry, driver, info) - self._last_on_color: dict[ColorComponent, int] | None = None - self._last_brightness: int | None = None + self._last_color: dict[str, int] | None = None + self._supported_color_modes.discard(ColorMode.BRIGHTNESS) @property - def brightness(self) -> int | None: - """Return the brightness of this light between 0..255. + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return 255 - Z-Wave multilevel switches use a range of [0, 99] to control brightness. - """ + @property + def is_on(self) -> bool | None: + """Return true if device is on (brightness above 0).""" if self.info.primary_value.value is None: return None - if self._target_brightness and self.info.primary_value.value is False: - # Binary switch exists and is turned off - return 0 - - # Brightness is encoded in the color channels by scaling them lower than 255 - color_values = [ - v.value - for v in self._get_color_values() - if v is not None and v.value is not None - ] - return max(color_values) if color_values else 0 + return any(value != 0 for value in self.info.primary_value.value.values()) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if ( kwargs.get(ATTR_RGBW_COLOR) is not None or kwargs.get(ATTR_COLOR_TEMP) is not None + or kwargs.get(ATTR_HS_COLOR) is not None ): - # RGBW and color temp are not supported in this mode, - # delegate to the parent class await super().async_turn_on(**kwargs) return transition = kwargs.get(ATTR_TRANSITION) - brightness = kwargs.get(ATTR_BRIGHTNESS) - hs_color = kwargs.get(ATTR_HS_COLOR) - new_colors: dict[ColorComponent, int] | None = None - scale: float | None = None - - if brightness is None and hs_color is None: - # Turned on without specifying brightness or color - if self._last_on_color is not None: - if self._target_brightness: - # Color is already set, use the binary switch to turn on - await self._async_set_brightness(None, transition) - return - - # Preserve the previous color - new_colors = self._last_on_color - elif self._supports_color: - # Turned on for the first time. Make it white - new_colors = { + # turn on light to last color if known, otherwise set to white + if self._last_color is not None: + await self._async_set_colors( + { + ColorComponent.RED: self._last_color["red"], + ColorComponent.GREEN: self._last_color["green"], + ColorComponent.BLUE: self._last_color["blue"], + }, + transition, + ) + else: + await self._async_set_colors( + { ColorComponent.RED: 255, ColorComponent.GREEN: 255, ColorComponent.BLUE: 255, - } - elif brightness is not None: - # If brightness gets set, preserve the color and mix it with the new brightness - if self.color_mode == ColorMode.HS: - scale = brightness / 255 - if ( - self._last_on_color is not None - and None not in self._last_on_color.values() - ): - # Changed brightness from 0 to >0 - old_brightness = max(self._last_on_color.values()) - new_scale = brightness / old_brightness - scale = new_scale - new_colors = {} - for color, value in self._last_on_color.items(): - new_colors[color] = round(value * new_scale) - elif hs_color is None and self._color_mode == ColorMode.HS: - hs_color = self._hs_color - elif hs_color is not None and brightness is None: - # Turned on by using the color controls - current_brightness = self.brightness - if current_brightness == 0 and self._last_brightness is not None: - # Use the last brightness value if the light is currently off - scale = self._last_brightness / 255 - elif current_brightness is not None: - scale = current_brightness / 255 - - # Reset last color until turning off again - self._last_on_color = None - - if new_colors is None: - new_colors = self._get_new_colors( - hs_color=hs_color, color_temp=None, rgbw=None, brightness_scale=scale + }, + transition, ) - if new_colors is not None: - await self._async_set_colors(new_colors, transition) - - # Turn the binary switch on if there is one - await self._async_set_brightness(brightness, transition) - async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - - # Remember last color and brightness to restore it when turning on - self._last_brightness = self.brightness - if self._current_color and isinstance(self._current_color.value, dict): - red = self._current_color.value.get(COLOR_SWITCH_COMBINED_RED) - green = self._current_color.value.get(COLOR_SWITCH_COMBINED_GREEN) - blue = self._current_color.value.get(COLOR_SWITCH_COMBINED_BLUE) - - last_color: dict[ColorComponent, int] = {} - if red is not None: - last_color[ColorComponent.RED] = red - if green is not None: - last_color[ColorComponent.GREEN] = green - if blue is not None: - last_color[ColorComponent.BLUE] = blue - - if last_color: - self._last_on_color = last_color - - if self._target_brightness: - # Turn off the binary switch only - await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION)) - else: - # turn off all color channels - colors = { + self._last_color = self.info.primary_value.value + await self._async_set_colors( + { ColorComponent.RED: 0, ColorComponent.GREEN: 0, ColorComponent.BLUE: 0, - } - - await self._async_set_colors( - colors, - kwargs.get(ATTR_TRANSITION), - ) + }, + kwargs.get(ATTR_TRANSITION), + ) + await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION)) diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 4c725c6dc29..376bd700a2a 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -8,7 +8,6 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, ATTR_COLOR_TEMP, - ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, ATTR_RGB_COLOR, @@ -38,8 +37,8 @@ from .common import ( ZEN_31_ENTITY, ) -ZDB5100_ENTITY = "light.matrix_office" HSM200_V1_ENTITY = "light.hsm200" +ZDB5100_ENTITY = "light.matrix_office" async def test_light( @@ -511,388 +510,14 @@ async def test_light_none_color_value( assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs"] -async def test_light_on_off_color( - hass: HomeAssistant, client, logic_group_zdb5100, integration -) -> None: - """Test the light entity for RGB lights without dimming support.""" - node = logic_group_zdb5100 - state = hass.states.get(ZDB5100_ENTITY) - assert state.state == STATE_OFF - - async def update_color(red: int, green: int, blue: int) -> None: - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Color Switch", - "commandClass": 51, - "endpoint": 1, - "property": "currentColor", - "propertyKey": 2, # red - "newValue": red, - "prevValue": None, - "propertyName": "currentColor", - "propertyKeyName": "red", - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Color Switch", - "commandClass": 51, - "endpoint": 1, - "property": "currentColor", - "propertyKey": 3, # green - "newValue": green, - "prevValue": None, - "propertyName": "currentColor", - "propertyKeyName": "green", - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Color Switch", - "commandClass": 51, - "endpoint": 1, - "property": "currentColor", - "propertyKey": 4, # blue - "newValue": blue, - "prevValue": None, - "propertyName": "currentColor", - "propertyKeyName": "blue", - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Color Switch", - "commandClass": 51, - "endpoint": 1, - "property": "currentColor", - "newValue": { - "red": red, - "green": green, - "blue": blue, - }, - "prevValue": None, - "propertyName": "currentColor", - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - async def update_switch_state(state: bool) -> None: - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Binary Switch", - "commandClass": 37, - "endpoint": 1, - "property": "currentValue", - "newValue": state, - "prevValue": None, - "propertyName": "currentValue", - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - # Turn on the light. Since this is the first call, the light should default to white - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ZDB5100_ENTITY}, - blocking=True, - ) - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 51, - "endpoint": 1, - "property": "targetColor", - } - assert args["value"] == { - "red": 255, - "green": 255, - "blue": 255, - } - - args = client.async_send_command.call_args_list[1][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 37, - "endpoint": 1, - "property": "targetValue", - } - assert args["value"] is True - - # Force the light to turn off - await update_switch_state(False) - - state = hass.states.get(ZDB5100_ENTITY) - assert state.state == STATE_OFF - - # Force the light to turn on (green) - await update_color(0, 255, 0) - await update_switch_state(True) - - state = hass.states.get(ZDB5100_ENTITY) - assert state.state == STATE_ON - - client.async_send_command.reset_mock() - - # Set the brightness to 128. This should be encoded in the color value - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ZDB5100_ENTITY, ATTR_BRIGHTNESS: 128}, - blocking=True, - ) - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 51, - "endpoint": 1, - "property": "targetColor", - } - assert args["value"] == { - "red": 0, - "green": 128, - "blue": 0, - } - - args = client.async_send_command.call_args_list[1][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 37, - "endpoint": 1, - "property": "targetValue", - } - assert args["value"] is True - - client.async_send_command.reset_mock() - - # Force the light to turn on (green, 50%) - await update_color(0, 128, 0) - - # Set the color to red. This should preserve the previous brightness value - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ZDB5100_ENTITY, ATTR_HS_COLOR: (0, 100)}, - blocking=True, - ) - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 51, - "endpoint": 1, - "property": "targetColor", - } - assert args["value"] == { - "red": 128, - "green": 0, - "blue": 0, - } - - args = client.async_send_command.call_args_list[1][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 37, - "endpoint": 1, - "property": "targetValue", - } - assert args["value"] is True - - client.async_send_command.reset_mock() - - # Force the light to turn on (red, 50%) - await update_color(128, 0, 0) - - # Turn the device off. This should only affect the binary switch, not the color - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ZDB5100_ENTITY}, - blocking=True, - ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 37, - "endpoint": 1, - "property": "targetValue", - } - assert args["value"] is False - - client.async_send_command.reset_mock() - - # Force the light to turn off - await update_switch_state(False) - - # Turn the device on again. This should only affect the binary switch, not the color - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ZDB5100_ENTITY}, - blocking=True, - ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 37, - "endpoint": 1, - "property": "targetValue", - } - assert args["value"] is True - - -async def test_light_color_only( +async def test_black_is_off( hass: HomeAssistant, client, express_controls_ezmultipli, integration ) -> None: - """Test the light entity for RGB lights with Color Switch CC only.""" + """Test the black is off light entity.""" node = express_controls_ezmultipli state = hass.states.get(HSM200_V1_ENTITY) assert state.state == STATE_ON - async def update_color(red: int, green: int, blue: int) -> None: - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Color Switch", - "commandClass": 51, - "endpoint": 0, - "property": "currentColor", - "propertyKey": 2, # red - "newValue": red, - "prevValue": None, - "propertyName": "currentColor", - "propertyKeyName": "red", - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Color Switch", - "commandClass": 51, - "endpoint": 0, - "property": "currentColor", - "propertyKey": 3, # green - "newValue": green, - "prevValue": None, - "propertyName": "currentColor", - "propertyKeyName": "green", - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Color Switch", - "commandClass": 51, - "endpoint": 0, - "property": "currentColor", - "propertyKey": 4, # blue - "newValue": blue, - "prevValue": None, - "propertyName": "currentColor", - "propertyKeyName": "blue", - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Color Switch", - "commandClass": 51, - "endpoint": 0, - "property": "currentColor", - "newValue": { - "red": red, - "green": green, - "blue": blue, - }, - "prevValue": None, - "propertyName": "currentColor", - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - # Attempt to turn on the light and ensure it defaults to white await hass.services.async_call( LIGHT_DOMAIN, @@ -914,14 +539,64 @@ async def test_light_color_only( client.async_send_command.reset_mock() # Force the light to turn off - await update_color(0, 0, 0) - + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 0, + "property": "currentColor", + "newValue": { + "red": 0, + "green": 0, + "blue": 0, + }, + "prevValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() state = hass.states.get(HSM200_V1_ENTITY) assert state.state == STATE_OFF - # Force the light to turn on (50% green) - await update_color(0, 128, 0) - + # Force the light to turn on + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 0, + "property": "currentColor", + "newValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "prevValue": { + "red": 0, + "green": 0, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() state = hass.states.get(HSM200_V1_ENTITY) assert state.state == STATE_ON @@ -944,9 +619,6 @@ async def test_light_color_only( client.async_send_command.reset_mock() - # Force the light to turn off - await update_color(0, 0, 0) - # Assert that the last color is restored await hass.services.async_call( LIGHT_DOMAIN, @@ -963,131 +635,11 @@ async def test_light_color_only( "endpoint": 0, "property": "targetColor", } - assert args["value"] == {"red": 0, "green": 128, "blue": 0} + assert args["value"] == {"red": 0, "green": 255, "blue": 0} client.async_send_command.reset_mock() - # Force the light to turn on (50% green) - await update_color(0, 128, 0) - - state = hass.states.get(HSM200_V1_ENTITY) - assert state.state == STATE_ON - - client.async_send_command.reset_mock() - - # Assert that the brightness is preserved when changing colors - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: HSM200_V1_ENTITY, ATTR_RGB_COLOR: (255, 0, 0)}, - blocking=True, - ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 51, - "endpoint": 0, - "property": "targetColor", - } - assert args["value"] == {"red": 128, "green": 0, "blue": 0} - - client.async_send_command.reset_mock() - - # Force the light to turn on (50% red) - await update_color(128, 0, 0) - - state = hass.states.get(HSM200_V1_ENTITY) - assert state.state == STATE_ON - - # Assert that the color is preserved when changing brightness - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: HSM200_V1_ENTITY, ATTR_BRIGHTNESS: 69}, - blocking=True, - ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 51, - "endpoint": 0, - "property": "targetColor", - } - assert args["value"] == {"red": 69, "green": 0, "blue": 0} - - client.async_send_command.reset_mock() - - await update_color(69, 0, 0) - - # Turn off again - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: HSM200_V1_ENTITY}, - blocking=True, - ) - await update_color(0, 0, 0) - - client.async_send_command.reset_mock() - - # Assert that the color is preserved when turning on with brightness - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: HSM200_V1_ENTITY, ATTR_BRIGHTNESS: 123}, - blocking=True, - ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 51, - "endpoint": 0, - "property": "targetColor", - } - assert args["value"] == {"red": 123, "green": 0, "blue": 0} - - client.async_send_command.reset_mock() - - await update_color(123, 0, 0) - - # Turn off again - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: HSM200_V1_ENTITY}, - blocking=True, - ) - await update_color(0, 0, 0) - - client.async_send_command.reset_mock() - - # Assert that the brightness is preserved when turning on with color - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: HSM200_V1_ENTITY, ATTR_HS_COLOR: (240, 100)}, - blocking=True, - ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == node.node_id - assert args["valueId"] == { - "commandClass": 51, - "endpoint": 0, - "property": "targetColor", - } - assert args["value"] == {"red": 0, "green": 0, "blue": 123} - - client.async_send_command.reset_mock() - - # Clear the color value to trigger an unknown state + # Force the light to turn on event = Event( type="value updated", data={ @@ -1100,14 +652,17 @@ async def test_light_color_only( "endpoint": 0, "property": "currentColor", "newValue": None, - "prevValue": None, + "prevValue": { + "red": 0, + "green": 255, + "blue": 0, + }, "propertyName": "currentColor", }, }, ) node.receive_event(event) await hass.async_block_till_done() - state = hass.states.get(HSM200_V1_ENTITY) assert state.state == STATE_UNKNOWN @@ -1132,6 +687,183 @@ async def test_light_color_only( assert args["value"] == {"red": 255, "green": 76, "blue": 255} +async def test_black_is_off_zdb5100( + hass: HomeAssistant, client, logic_group_zdb5100, integration +) -> None: + """Test the black is off light entity.""" + node = logic_group_zdb5100 + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_OFF + + # Attempt to turn on the light and ensure it defaults to white + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 255, "green": 255, "blue": 255} + + client.async_send_command.reset_mock() + + # Force the light to turn off + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": { + "red": 0, + "green": 0, + "blue": 0, + }, + "prevValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_OFF + + # Force the light to turn on + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "prevValue": { + "red": 0, + "green": 0, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 0, "green": 0, "blue": 0} + + client.async_send_command.reset_mock() + + # Assert that the last color is restored + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 0, "green": 255, "blue": 0} + + client.async_send_command.reset_mock() + + # Force the light to turn on + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": None, + "prevValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_UNKNOWN + + client.async_send_command.reset_mock() + + # Assert that call fails if attribute is added to service call + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY, ATTR_RGBW_COLOR: (255, 76, 255, 0)}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 255, "green": 76, "blue": 255} + + async def test_basic_cc_light( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 4e4f8ee3a425ec94ca1a3f131091d6d93bf20915 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Oct 2024 09:26:37 +0200 Subject: [PATCH 110/134] Bump version to 2024.10.0b9 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3d90fbc0663..a0af7e248c8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0b9" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index a9127b5c896..d00ee684784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b8" +version = "2024.10.0b9" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From b9795a2ae7dd414e223020c5758fb0280f61dc87 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Oct 2024 09:56:36 +0200 Subject: [PATCH 111/134] Make recorder WS command recorder/update_statistics_metadata wait (#127179) --- homeassistant/components/recorder/core.py | 3 ++- homeassistant/components/recorder/tasks.py | 3 +++ .../components/recorder/websocket_api.py | 25 ++++++++++++++++--- .../components/recorder/test_websocket_api.py | 25 +++++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 0c80d979268..5f598c6ce40 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -581,11 +581,12 @@ class Recorder(threading.Thread): *, new_statistic_id: str | UndefinedType = UNDEFINED, new_unit_of_measurement: str | None | UndefinedType = UNDEFINED, + on_done: Callable[[], None] | None = None, ) -> None: """Update statistics metadata for a statistic_id.""" self.queue_task( UpdateStatisticsMetadataTask( - statistic_id, new_statistic_id, new_unit_of_measurement + on_done, statistic_id, new_statistic_id, new_unit_of_measurement ) ) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 2529e8012bf..ce517377772 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -71,6 +71,7 @@ class ClearStatisticsTask(RecorderTask): class UpdateStatisticsMetadataTask(RecorderTask): """Object to store statistics_id and unit for update of statistics metadata.""" + on_done: Callable[[], None] | None statistic_id: str new_statistic_id: str | None | UndefinedType new_unit_of_measurement: str | None | UndefinedType @@ -83,6 +84,8 @@ class UpdateStatisticsMetadataTask(RecorderTask): self.new_statistic_id, self.new_unit_of_measurement, ) + if self.on_done: + self.on_done() @dataclass(slots=True) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 6ac2207b1e0..9e4de946c0b 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import datetime as dt from typing import Any, Literal, cast @@ -48,6 +49,8 @@ from .statistics import ( ) from .util import PERIOD_SCHEMA, get_instance, resolve_period +UPDATE_STATISTICS_METADATA_TIME_OUT = 10 + UNIT_SCHEMA = vol.Schema( { vol.Optional("conductivity"): vol.In(ConductivityConverter.VALID_UNITS), @@ -357,17 +360,33 @@ async def ws_get_statistics_metadata( vol.Required("unit_of_measurement"): vol.Any(str, None), } ) -@callback -def ws_update_statistics_metadata( +@websocket_api.async_response +async def ws_update_statistics_metadata( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Update statistics metadata for a statistic_id. Only the normalized unit of measurement can be updated. """ + done_event = asyncio.Event() + + def update_statistics_metadata_done() -> None: + hass.loop.call_soon_threadsafe(done_event.set) + get_instance(hass).async_update_statistics_metadata( - msg["statistic_id"], new_unit_of_measurement=msg["unit_of_measurement"] + msg["statistic_id"], + new_unit_of_measurement=msg["unit_of_measurement"], + on_done=update_statistics_metadata_done, ) + try: + async with asyncio.timeout(UPDATE_STATISTICS_METADATA_TIME_OUT): + await done_event.wait() + except TimeoutError: + connection.send_error( + msg["id"], websocket_api.ERR_TIMEOUT, "update_statistics_metadata timed out" + ) + return + connection.send_result(msg["id"]) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index badf2540654..70ad3358430 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2216,6 +2216,31 @@ async def test_update_statistics_metadata( } +async def test_update_statistics_metadata_time_out( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test update statistics metadata with time-out error.""" + client = await hass_ws_client() + + with ( + patch.object(recorder.tasks.UpdateStatisticsMetadataTask, "run"), + patch.object(recorder.websocket_api, "UPDATE_STATISTICS_METADATA_TIME_OUT", 0), + ): + await client.send_json_auto_id( + { + "type": "recorder/update_statistics_metadata", + "statistic_id": "sensor.test", + "unit_of_measurement": "dogs", + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "timeout", + "message": "update_statistics_metadata timed out", + } + + async def test_change_statistics_unit( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: From 565203047c114b991481b04c9fe83f3db087911a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 2 Oct 2024 10:43:01 +0200 Subject: [PATCH 112/134] Update frontend to 20241002.0 (#127264) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index decdf737e3d..f7478eacfe9 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240930.0"] + "requirements": ["home-assistant-frontend==20241002.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bd7bab352c9..cfadbdfdd2a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20240930.0 +home-assistant-frontend==20241002.0 home-assistant-intents==2024.9.23 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 347d5351163..f90dd814c56 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1117,7 +1117,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20240930.0 +home-assistant-frontend==20241002.0 # homeassistant.components.conversation home-assistant-intents==2024.9.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4eeb4211094..6255b85ceab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -943,7 +943,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20240930.0 +home-assistant-frontend==20241002.0 # homeassistant.components.conversation home-assistant-intents==2024.9.23 From 9c28a4e8a0fef55f6a53727add90e254b2a2ac1b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Oct 2024 10:43:40 +0200 Subject: [PATCH 113/134] Make recorder WS command recorder/clear_statistics wait (#127120) --- homeassistant/components/recorder/core.py | 6 +++-- homeassistant/components/recorder/tasks.py | 3 +++ .../components/recorder/websocket_api.py | 23 +++++++++++++++--- .../components/recorder/test_websocket_api.py | 24 +++++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 5f598c6ce40..4866c8d536a 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -570,9 +570,11 @@ class Recorder(threading.Thread): ) @callback - def async_clear_statistics(self, statistic_ids: list[str]) -> None: + def async_clear_statistics( + self, statistic_ids: list[str], *, on_done: Callable[[], None] | None = None + ) -> None: """Clear statistics for a list of statistic_ids.""" - self.queue_task(ClearStatisticsTask(statistic_ids)) + self.queue_task(ClearStatisticsTask(on_done, statistic_ids)) @callback def async_update_statistics_metadata( diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index ce517377772..783f0a80b8e 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -60,11 +60,14 @@ class ChangeStatisticsUnitTask(RecorderTask): class ClearStatisticsTask(RecorderTask): """Object to store statistics_ids which for which to remove statistics.""" + on_done: Callable[[], None] | None statistic_ids: list[str] def run(self, instance: Recorder) -> None: """Handle the task.""" statistics.clear_statistics(instance, self.statistic_ids) + if self.on_done: + self.on_done() @dataclass(slots=True) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 9e4de946c0b..ac917e903df 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -49,6 +49,7 @@ from .statistics import ( ) from .util import PERIOD_SCHEMA, get_instance, resolve_period +CLEAR_STATISTICS_TIME_OUT = 10 UPDATE_STATISTICS_METADATA_TIME_OUT = 10 UNIT_SCHEMA = vol.Schema( @@ -322,8 +323,8 @@ async def ws_update_statistics_issues( vol.Required("statistic_ids"): [str], } ) -@callback -def ws_clear_statistics( +@websocket_api.async_response +async def ws_clear_statistics( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Clear statistics for a list of statistic_ids. @@ -331,7 +332,23 @@ def ws_clear_statistics( Note: The WS call posts a job to the recorder's queue and then returns, it doesn't wait until the job is completed. """ - get_instance(hass).async_clear_statistics(msg["statistic_ids"]) + done_event = asyncio.Event() + + def clear_statistics_done() -> None: + hass.loop.call_soon_threadsafe(done_event.set) + + get_instance(hass).async_clear_statistics( + msg["statistic_ids"], on_done=clear_statistics_done + ) + try: + async with asyncio.timeout(CLEAR_STATISTICS_TIME_OUT): + await done_event.wait() + except TimeoutError: + connection.send_error( + msg["id"], websocket_api.ERR_TIMEOUT, "clear_statistics timed out" + ) + return + connection.send_result(msg["id"]) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 70ad3358430..547288d1cc3 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2116,6 +2116,30 @@ async def test_clear_statistics( assert response["result"] == {"sensor.test2": expected_response["sensor.test2"]} +async def test_clear_statistics_time_out( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing statistics with time-out error.""" + client = await hass_ws_client() + + with ( + patch.object(recorder.tasks.ClearStatisticsTask, "run"), + patch.object(recorder.websocket_api, "CLEAR_STATISTICS_TIME_OUT", 0), + ): + await client.send_json_auto_id( + { + "type": "recorder/clear_statistics", + "statistic_ids": ["sensor.test"], + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "timeout", + "message": "clear_statistics timed out", + } + + @pytest.mark.parametrize( ("new_unit", "new_unit_class", "new_display_unit"), [("dogs", None, "dogs"), (None, "unitless", None), ("W", "power", "kW")], From 5365439fd41be7b4c62eba50276a4995d0926262 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Oct 2024 10:52:33 +0200 Subject: [PATCH 114/134] Bump version to 2024.10.0b10 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a0af7e248c8..b3051cd3dc5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b9" +PATCH_VERSION: Final = "0b10" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index d00ee684784..0cc5038aa9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b9" +version = "2024.10.0b10" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 48538ef5d554bf0ef1ac66fe052c6f00c774a110 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 2 Oct 2024 11:25:04 +0200 Subject: [PATCH 115/134] Fix climate entity in ViCare integration (#127128) do not reset _attributes --- homeassistant/components/vicare/climate.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index b742ad257fa..8a116038533 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -167,7 +167,9 @@ class ViCareClimate(ViCareEntity, ClimateEntity): try: _room_temperature = None with suppress(PyViCareNotSupportedFeatureError): - _room_temperature = self._api.getRoomTemperature() + self._attributes["room_temperature"] = _room_temperature = ( + self._api.getRoomTemperature() + ) _supply_temperature = None with suppress(PyViCareNotSupportedFeatureError): @@ -181,20 +183,17 @@ class ViCareClimate(ViCareEntity, ClimateEntity): self._attr_current_temperature = None with suppress(PyViCareNotSupportedFeatureError): - self._current_program = self._api.getActiveProgram() + self._attributes["active_vicare_program"] = self._current_program = ( + self._api.getActiveProgram() + ) with suppress(PyViCareNotSupportedFeatureError): self._attr_target_temperature = self._api.getCurrentDesiredTemperature() with suppress(PyViCareNotSupportedFeatureError): - self._current_mode = self._api.getActiveMode() - - # Update the generic device attributes - self._attributes = { - "room_temperature": _room_temperature, - "active_vicare_program": self._current_program, - "active_vicare_mode": self._current_mode, - } + self._attributes["active_vicare_mode"] = self._current_mode = ( + self._api.getActiveMode() + ) with suppress(PyViCareNotSupportedFeatureError): self._attributes["heating_curve_slope"] = ( From 7d3dd2dd6b3e9e1d159ab69a87a90427c5f74670 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 2 Oct 2024 14:18:28 +0200 Subject: [PATCH 116/134] Update frontend to 20241002.1 (#127292) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index f7478eacfe9..42eece5d634 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241002.0"] + "requirements": ["home-assistant-frontend==20241002.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cfadbdfdd2a..fbe2c155d98 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20241002.0 +home-assistant-frontend==20241002.1 home-assistant-intents==2024.9.23 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f90dd814c56..134b2db43ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1117,7 +1117,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20241002.0 +home-assistant-frontend==20241002.1 # homeassistant.components.conversation home-assistant-intents==2024.9.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6255b85ceab..3f4ca17e40c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -943,7 +943,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20241002.0 +home-assistant-frontend==20241002.1 # homeassistant.components.conversation home-assistant-intents==2024.9.23 From 7ac944c537c566e5ae9d0f92e82ef8a2273b2400 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Oct 2024 16:01:13 +0200 Subject: [PATCH 117/134] Bump version to 2024.10.0b11 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b3051cd3dc5..468a635998f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b10" +PATCH_VERSION: Final = "0b11" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 0cc5038aa9d..9d50a86cb1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b10" +version = "2024.10.0b11" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From a6808a8fdaf5cd15b2513f1a6b23352792348954 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 2 Oct 2024 17:11:51 +0200 Subject: [PATCH 118/134] Update frontend to 20241002.2 (#127331) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 42eece5d634..9f79dcf34f6 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241002.1"] + "requirements": ["home-assistant-frontend==20241002.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fbe2c155d98..e54c7d62a80 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20241002.1 +home-assistant-frontend==20241002.2 home-assistant-intents==2024.9.23 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 134b2db43ad..e5e1dbb109f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1117,7 +1117,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20241002.1 +home-assistant-frontend==20241002.2 # homeassistant.components.conversation home-assistant-intents==2024.9.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f4ca17e40c..1537ebeb7e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -943,7 +943,7 @@ hole==0.8.0 holidays==0.57 # homeassistant.components.frontend -home-assistant-frontend==20241002.1 +home-assistant-frontend==20241002.2 # homeassistant.components.conversation home-assistant-intents==2024.9.23 From a50b299a823799c46e1e036315a3f01666b643f7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Oct 2024 17:18:01 +0200 Subject: [PATCH 119/134] Bump version to 2024.10.0b12 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 468a635998f..5d167c0e37c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b11" +PATCH_VERSION: Final = "0b12" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 9d50a86cb1a..eed40a491dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b11" +version = "2024.10.0b12" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From a052e15319288a90aceebb0e202b4426b8dd32f6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Oct 2024 17:18:59 +0200 Subject: [PATCH 120/134] Bump pychromecast to 14.0.2 (#127333) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 1d06ae23ca2..27b5ba52d79 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==14.0.1"], + "requirements": ["PyChromecast==14.0.2"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e5e1dbb109f..bed785404f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==14.0.1 +PyChromecast==14.0.2 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1537ebeb7e8..e15e0d8771a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -42,7 +42,7 @@ PlexAPI==4.15.16 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==14.0.1 +PyChromecast==14.0.2 # homeassistant.components.flick_electric PyFlick==0.0.2 From dc7c909316c1afdaca48399e60e999cfdb80b1ef Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 2 Oct 2024 11:58:31 -0500 Subject: [PATCH 121/134] Bump intents to 2024.10.2 (#127338) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 79869510027..c2168ce7152 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.9.23"] + "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.10.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e54c7d62a80..1da76f572a1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 home-assistant-frontend==20241002.2 -home-assistant-intents==2024.9.23 +home-assistant-intents==2024.10.2 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index bed785404f7..78c90a57fe6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1120,7 +1120,7 @@ holidays==0.57 home-assistant-frontend==20241002.2 # homeassistant.components.conversation -home-assistant-intents==2024.9.23 +home-assistant-intents==2024.10.2 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e15e0d8771a..9281f059bef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -946,7 +946,7 @@ holidays==0.57 home-assistant-frontend==20241002.2 # homeassistant.components.conversation -home-assistant-intents==2024.9.23 +home-assistant-intents==2024.10.2 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 970e987cc1d..43aea987810 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.15,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.6.6 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.9.23 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.10.2 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From acb0aeaa9a8d8aef832f6f436196bc1fa74f804b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Oct 2024 19:17:08 +0200 Subject: [PATCH 122/134] Bump version to 2024.10.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5d167c0e37c..b1ac28494c9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b12" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index eed40a491dc..465cbf0de5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0b12" +version = "2024.10.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 6ab92abe80250c3eb9d0dfe0b92c3acf566ec8c1 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 2 Oct 2024 19:04:36 +0200 Subject: [PATCH 123/134] Fix device id support for alarm control panel template (#127340) --- .../template/alarm_control_panel.py | 6 ++- .../template/test_alarm_control_panel.py | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 0d9e5ebc8ce..6c8a70b328e 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -38,6 +38,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import selector import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -233,7 +234,10 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore self._trigger_script = Script(hass, trigger_action, name, DOMAIN) self._state: str | None = None - + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) supported_features = AlarmControlPanelEntityFeature(0) if self._arm_night_script is not None: supported_features = ( diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 1532197d738..8890d790b87 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -23,6 +23,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache @@ -503,3 +504,45 @@ async def test_restore_state( state = hass.states.get("alarm_control_panel.test_template_panel") assert state.state == initial_state + + +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for device for button template.""" + + device_config_entry = MockConfigEntry() + device_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=device_config_entry.entry_id, + identifiers={("test", "identifier_test")}, + connections={("mac", "30:31:32:33:34:35")}, + ) + await hass.async_block_till_done() + assert device_entry is not None + assert device_entry.id is not None + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "value_template": "disarmed", + "template_type": "alarm_control_panel", + "code_arm_required": True, + "code_format": "number", + "device_id": device_entry.id, + }, + title="My template", + ) + + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + template_entity = entity_registry.async_get("alarm_control_panel.my_template") + assert template_entity is not None + assert template_entity.device_id == device_entry.id From 7d9e170512211f1e1c58ee9c163fa5128a3de1ec Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 3 Oct 2024 20:30:13 +1000 Subject: [PATCH 124/134] Bump pysmlight 0.1.2 (#127376) Co-authored-by: Tim Lunn --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3f4a0c69b24..10984e8efb1 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.1"], + "requirements": ["pysmlight==0.1.2"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 78c90a57fe6..d8743065fc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2244,7 +2244,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.1 +pysmlight==0.1.2 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9281f059bef..eb436301d63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1798,7 +1798,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.1 +pysmlight==0.1.2 # homeassistant.components.snmp pysnmp==6.2.6 From b2b940fc3281267c1f944b9e33fa789ab56962a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 3 Oct 2024 22:27:15 +0200 Subject: [PATCH 125/134] Remove assumption in ConfigEntryItems about unique unique_id (#127399) --- homeassistant/config_entries.py | 17 ++++++++++------ tests/test_config_entries.py | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 404ae1c91dd..f9dc9191c8e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1558,7 +1558,7 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): super().__init__() self._hass = hass self._domain_index: dict[str, list[ConfigEntry]] = {} - self._domain_unique_id_index: dict[str, dict[str, ConfigEntry]] = {} + self._domain_unique_id_index: dict[str, dict[str, list[ConfigEntry]]] = {} def values(self) -> ValuesView[ConfigEntry]: """Return the underlying values to avoid __iter__ overhead.""" @@ -1601,9 +1601,9 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): report_issue, ) - self._domain_unique_id_index.setdefault(entry.domain, {})[ - unique_id_hash - ] = entry + self._domain_unique_id_index.setdefault(entry.domain, {}).setdefault( + unique_id_hash, [] + ).append(entry) def _unindex_entry(self, entry_id: str) -> None: """Unindex an entry.""" @@ -1616,7 +1616,9 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): # Check type first to avoid expensive isinstance call if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721 unique_id = str(entry.unique_id) # type: ignore[unreachable] - del self._domain_unique_id_index[domain][unique_id] + self._domain_unique_id_index[domain][unique_id].remove(entry) + if not self._domain_unique_id_index[domain][unique_id]: + del self._domain_unique_id_index[domain][unique_id] if not self._domain_unique_id_index[domain]: del self._domain_unique_id_index[domain] @@ -1647,7 +1649,10 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): # Check type first to avoid expensive isinstance call if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721 unique_id = str(unique_id) # type: ignore[unreachable] - return self._domain_unique_id_index.get(domain, {}).get(unique_id) + entries = self._domain_unique_id_index.get(domain, {}).get(unique_id) + if not entries: + return None + return entries[0] class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]): diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 9cba19ef3b1..92cec00ccdf 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -512,6 +512,41 @@ async def test_remove_entry( assert not entity_entry_list +async def test_remove_entry_non_unique_unique_id( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + entity_registry: er.EntityRegistry, +) -> None: + """Test that we can remove entry with colliding unique_id.""" + entry_1 = MockConfigEntry( + domain="test_other", entry_id="test1", unique_id="not_unique" + ) + entry_1.add_to_manager(manager) + entry_2 = MockConfigEntry( + domain="test_other", entry_id="test2", unique_id="not_unique" + ) + entry_2.add_to_manager(manager) + entry_3 = MockConfigEntry( + domain="test_other", entry_id="test3", unique_id="not_unique" + ) + entry_3.add_to_manager(manager) + + # Check all config entries exist + assert manager.async_entry_ids() == [ + "test1", + "test2", + "test3", + ] + + # Remove entries + assert await manager.async_remove("test1") == {"require_restart": False} + await hass.async_block_till_done() + assert await manager.async_remove("test2") == {"require_restart": False} + await hass.async_block_till_done() + assert await manager.async_remove("test3") == {"require_restart": False} + await hass.async_block_till_done() + + async def test_remove_entry_cancels_reauth( hass: HomeAssistant, manager: config_entries.ConfigEntries, From 3f9287c36b4a199cd796d1cd7a1c9caacb22031f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 3 Oct 2024 20:10:03 +1000 Subject: [PATCH 126/134] Add missing number platform to init of Tesla Fleet (#127406) Add number platform to init --- homeassistant/components/tesla_fleet/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 61f9dc66ffc..4cd8c5c7142 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -47,6 +47,7 @@ PLATFORMS: Final = [ Platform.DEVICE_TRACKER, Platform.LOCK, Platform.MEDIA_PLAYER, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, From e53bd477b4f759547b0da0477edbe6c3d680253f Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Thu, 3 Oct 2024 16:35:46 +0100 Subject: [PATCH 127/134] Bump aiomealie to 0.9.3 (#127454) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 4fabdffadc4..f594f1398e3 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mealie", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["aiomealie==0.9.2"] + "requirements": ["aiomealie==0.9.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index d8743065fc1..b3669420b7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -294,7 +294,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.2 +aiomealie==0.9.3 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb436301d63..929644f87a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -276,7 +276,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.2 +aiomealie==0.9.3 # homeassistant.components.modern_forms aiomodernforms==0.1.8 From 1ebde4a88041ae105e32bedf7163f743baccc8c3 Mon Sep 17 00:00:00 2001 From: robinostlund Date: Fri, 4 Oct 2024 12:02:33 +0200 Subject: [PATCH 128/134] Fix int value in unique_id for Tellduslive (#127526) Fix int in unique_id --- homeassistant/components/tellduslive/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index e588ea6318f..9bd2b1fe599 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -194,4 +194,4 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): @property def unique_id(self) -> str: """Return a unique ID.""" - return "-".join(self._id) + return "-".join(map(str, self._id)) From 1b0f731e30b7c6bdb51c894ec56dfe563c433387 Mon Sep 17 00:00:00 2001 From: Paarth Shah Date: Fri, 4 Oct 2024 02:11:39 -0700 Subject: [PATCH 129/134] Bump matrix-nio to 0.25.2 (#127535) --- homeassistant/components/matrix/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index cd4e5327608..520bd0550cc 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-nio==0.25.1", "Pillow==10.4.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==10.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b3669420b7d..509b44154a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1324,7 +1324,7 @@ lw12==0.9.2 lxml==5.3.0 # homeassistant.components.matrix -matrix-nio==0.25.1 +matrix-nio==0.25.2 # homeassistant.components.maxcube maxcube-api==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 929644f87a1..fa4d2af59e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1099,7 +1099,7 @@ lupupy==0.3.2 lxml==5.3.0 # homeassistant.components.matrix -matrix-nio==0.25.1 +matrix-nio==0.25.2 # homeassistant.components.maxcube maxcube-api==0.4.3 From ea8aa6b07d024d8140a617f9bf74ccff8f0709e1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 4 Oct 2024 11:21:22 +0200 Subject: [PATCH 130/134] Adjust polling rate of Rituals Perfume Genie (#127544) --- .../components/rituals_perfume_genie/__init__.py | 9 +++++++-- .../components/rituals_perfume_genie/config_flow.py | 1 + .../components/rituals_perfume_genie/const.py | 6 +++++- .../components/rituals_perfume_genie/coordinator.py | 12 +++++++++--- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index 792a470ca3c..d0d16ba6324 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ACCOUNT_HASH, DOMAIN +from .const import ACCOUNT_HASH, DOMAIN, UPDATE_INTERVAL from .coordinator import RitualsDataUpdateCoordinator PLATFORMS = [ @@ -37,9 +37,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Migrate old unique_ids to the new format async_migrate_entities_unique_ids(hass, entry, account_devices) + # The API provided by Rituals is currently rate limited to 30 requests + # per hour per IP address. To avoid hitting this limit, we will adjust + # the polling interval based on the number of diffusers one has. + update_interval = UPDATE_INTERVAL * len(account_devices) + # Create a coordinator for each diffuser coordinators = { - diffuser.hublot: RitualsDataUpdateCoordinator(hass, diffuser) + diffuser.hublot: RitualsDataUpdateCoordinator(hass, diffuser, update_interval) for diffuser in account_devices } diff --git a/homeassistant/components/rituals_perfume_genie/config_flow.py b/homeassistant/components/rituals_perfume_genie/config_flow.py index 4f108d9bc22..f6736ab78e4 100644 --- a/homeassistant/components/rituals_perfume_genie/config_flow.py +++ b/homeassistant/components/rituals_perfume_genie/config_flow.py @@ -45,6 +45,7 @@ class RitualsPerfumeGenieConfigFlow(ConfigFlow, domain=DOMAIN): try: await account.authenticate() except ClientResponseError: + _LOGGER.exception("Unexpected response") errors["base"] = "cannot_connect" except AuthenticationException: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/rituals_perfume_genie/const.py b/homeassistant/components/rituals_perfume_genie/const.py index 35d1c32d306..45428ced9d2 100644 --- a/homeassistant/components/rituals_perfume_genie/const.py +++ b/homeassistant/components/rituals_perfume_genie/const.py @@ -6,4 +6,8 @@ DOMAIN = "rituals_perfume_genie" ACCOUNT_HASH = "account_hash" -UPDATE_INTERVAL = timedelta(minutes=2) +# The API provided by Rituals is currently rate limited to 30 requests +# per hour per IP address. To avoid hitting this limit, the polling +# interval is set to 3 minutes. This also gives a little room for +# Home Assistant restarts. +UPDATE_INTERVAL = timedelta(minutes=3) diff --git a/homeassistant/components/rituals_perfume_genie/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py index 4c86f110b17..a83e823bd4e 100644 --- a/homeassistant/components/rituals_perfume_genie/coordinator.py +++ b/homeassistant/components/rituals_perfume_genie/coordinator.py @@ -1,5 +1,6 @@ """The Rituals Perfume Genie data update coordinator.""" +from datetime import timedelta import logging from pyrituals import Diffuser @@ -7,7 +8,7 @@ from pyrituals import Diffuser from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, UPDATE_INTERVAL +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -15,14 +16,19 @@ _LOGGER = logging.getLogger(__name__) class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" - def __init__(self, hass: HomeAssistant, diffuser: Diffuser) -> None: + def __init__( + self, + hass: HomeAssistant, + diffuser: Diffuser, + update_interval: timedelta, + ) -> None: """Initialize global Rituals Perfume Genie data updater.""" self.diffuser = diffuser super().__init__( hass, _LOGGER, name=f"{DOMAIN}-{diffuser.hublot}", - update_interval=UPDATE_INTERVAL, + update_interval=update_interval, ) async def _async_update_data(self) -> None: From 6b814afd39cbd46bf6303f89d56c221246b0d826 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 4 Oct 2024 12:04:33 +0200 Subject: [PATCH 131/134] Create new clientsession for NYT Games (#127547) --- homeassistant/components/nyt_games/__init__.py | 4 ++-- homeassistant/components/nyt_games/config_flow.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nyt_games/__init__.py b/homeassistant/components/nyt_games/__init__.py index ae35b40d29f..94dc22fe89e 100644 --- a/homeassistant/components/nyt_games/__init__.py +++ b/homeassistant/components/nyt_games/__init__.py @@ -7,7 +7,7 @@ from nyt_games import NYTGamesClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import NYTGamesCoordinator @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NYTGamesConfigEntry) -> """Set up NYTGames from a config entry.""" client = NYTGamesClient( - entry.data[CONF_TOKEN], session=async_get_clientsession(hass) + entry.data[CONF_TOKEN], session=async_create_clientsession(hass) ) coordinator = NYTGamesCoordinator(hass, client) diff --git a/homeassistant/components/nyt_games/config_flow.py b/homeassistant/components/nyt_games/config_flow.py index 03247d6c194..6676cfad34a 100644 --- a/homeassistant/components/nyt_games/config_flow.py +++ b/homeassistant/components/nyt_games/config_flow.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_TOKEN -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import DOMAIN, LOGGER @@ -21,7 +21,7 @@ class NYTGamesConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors: dict[str, str] = {} if user_input: - session = async_get_clientsession(self.hass) + session = async_create_clientsession(self.hass) client = NYTGamesClient(user_input[CONF_TOKEN], session=session) try: user_id = await client.get_user_id() From 087566072d39b0b491193278ea62149b192b0821 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 4 Oct 2024 12:53:35 +0200 Subject: [PATCH 132/134] Strip the NYT Games token (#127548) --- .../components/nyt_games/config_flow.py | 7 +++++-- .../components/nyt_games/test_config_flow.py | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nyt_games/config_flow.py b/homeassistant/components/nyt_games/config_flow.py index 6676cfad34a..bfed1f47c41 100644 --- a/homeassistant/components/nyt_games/config_flow.py +++ b/homeassistant/components/nyt_games/config_flow.py @@ -22,7 +22,8 @@ class NYTGamesConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input: session = async_create_clientsession(self.hass) - client = NYTGamesClient(user_input[CONF_TOKEN], session=session) + token = user_input[CONF_TOKEN].strip() + client = NYTGamesClient(token, session=session) try: user_id = await client.get_user_id() except NYTGamesAuthenticationError: @@ -35,7 +36,9 @@ class NYTGamesConfigFlow(ConfigFlow, domain=DOMAIN): else: await self.async_set_unique_id(str(user_id)) self._abort_if_unique_id_configured() - return self.async_create_entry(title="NYT Games", data=user_input) + return self.async_create_entry( + title="NYT Games", data={CONF_TOKEN: token} + ) return self.async_show_form( step_id="user", data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}), diff --git a/tests/components/nyt_games/test_config_flow.py b/tests/components/nyt_games/test_config_flow.py index 144b3a3ad17..bd17724887e 100644 --- a/tests/components/nyt_games/test_config_flow.py +++ b/tests/components/nyt_games/test_config_flow.py @@ -37,6 +37,27 @@ async def test_full_flow( assert result["result"].unique_id == "218886794" +async def test_stripping_token( + hass: HomeAssistant, + mock_nyt_games_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test stripping token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: " token "}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_TOKEN: "token"} + + @pytest.mark.parametrize( ("exception", "error"), [ From c52607b465db62d6b2e45876b177e9382b46d55f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 4 Oct 2024 13:42:35 +0200 Subject: [PATCH 133/134] Revert "Bump pychromecast to 14.0.2 (#127333)" (#127555) This reverts commit 2ab66f62fa7ce3a5b60db1c53ec84dd4701c36ce. --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 27b5ba52d79..1d06ae23ca2 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==14.0.2"], + "requirements": ["PyChromecast==14.0.1"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 509b44154a6..2563b7a1eb9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==14.0.2 +PyChromecast==14.0.1 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa4d2af59e4..bee8274ca60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -42,7 +42,7 @@ PlexAPI==4.15.16 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==14.0.2 +PyChromecast==14.0.1 # homeassistant.components.flick_electric PyFlick==0.0.2 From 2cbf53ad7b3b2a584ae2aab91d1a3a6a5edb16b9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 4 Oct 2024 14:57:14 +0200 Subject: [PATCH 134/134] Bump version to 2024.10.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b1ac28494c9..26049ed326b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 465cbf0de5f..955aac83f36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.10.0" +version = "2024.10.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"